I read A guide to Windows application development using w64devkit and was amazed at how much was accomplished with so little. Key to the author's suggestions was simply using C and leveraging native libraries and APIs; the trouble is I don't even know what I don't know! Looking for a place to start I thought it would be instructive to pick apart the example program provided — Asteroids.
The entire program is pretty small to begin with but in the interest of brevity I stripped out a few niceties like audio and joystick handling because they seemed inessential to understanding the game. As a result I've pared the program down to just shy of 750 lines of code. While the entire listing is on this page along the left hand side, the entire file is also available in it's modified form here.
#include <math.h> #include <stdint.h> #include <windows.h> #include <GL/gl.h>
Basic includes for both application window drawing as well as the OpenGL game bits.
#define COUNTOF(a) (int)(sizeof(a) / sizeof(0[a]))
A macro to return the number of elements in an array by dividing
the array's sizeof
by the sizeof
a
single element. The weird syntax sizeof(0[a])
is
equivalent to sizeof(a[0])
because of the way array
indexing in C translates to pointer addition for offsetting a
start address. So here you can imagine a + 0
vs. 0 + a
. As far as I know, the intent in using
this particular syntax is to trigger errors in the case the
macro is used in C++ with a type that has
overloaded []
.
#define PI 0x1.921fb6p+1f #define FATAL(msg) \ do { \ MessageBoxA(0, msg, "Fatal Error", MB_OK); \ ExitProcess(-1); \ } while (0)
Define π as 0x1.921fb6p+1f which is apparently the closest single-precision hexadecimal floating point constant. The value following the p is the binary exponent, the trailing f indicates float.
MessageBoxA
is the first real Windows-ism we find,
it
is a
modal dialog. The do-while
loop is used to
collect several statements in a way that looks like a function
inside a C macro.
#define INIT_COUNT 8 #define LEVEL_DELAY 3 #define TIME_STEP_MIN 1.0f/15 #define SHIP_TURN_RATE PI #define SHIP_DAMPEN 0.995f #define SHIP_ACCEL 0.5f #define SHIP_SCALE 0.025f #define SHOT_SPEED 0.5f #define SHOT_TTL 1.0f #define SHOT_COOLDOWN 0.2f #define DEBRIS_TTL 1.2f #define ASTEROID0_MIN 0.025f #define ASTEROID0_MAX 0.050f #define ASTEROID0_SCORE 1 #define ASTEROID1_MIN 0.012f #define ASTEROID1_MAX 0.025f #define ASTEROID1_SCORE 2 #define ASTEROID2_MIN 0.008f #define ASTEROID2_MAX 0.015f #define ASTEROID2_SCORE 5 enum asteroid_size {A0, A1, A2};
Lots of constants!
Additionally, we get a hint at the inner-working of the game with an enumeration of three different asteroid sizes. Correspondingly we see there are constants defined for each type (I'm guess they are equivalent to small, medium, and large).
/* format: ARGB */ #define C_SHIP 0xffffffff #define C_ASTEROID 0xffffffff #define C_SHOT 0xffffffff #define C_THRUST 0xffffff00 #define C_FIRE 0x00df9f5f #define C_SCORE 0xffffffff
More constants, this time defining colors for game objects. I'm
guessing ARGB is alpha, red, green, and blue and each number is
a composite of four different 8-bit numbers. It is easy to
verify this theory. Change for example C_ASTEROID
to 0xffff0000
and observe all the asteroids are now
red instead of white:
struct v2 { float x, y; }; static const struct v2 ship[] = { {+1.00f * SHIP_SCALE, +0.00f * SHIP_SCALE}, {-0.71f * SHIP_SCALE, +0.57f * SHIP_SCALE}, {-0.43f * SHIP_SCALE, +0.29f * SHIP_SCALE}, {-0.43f * SHIP_SCALE, -0.29f * SHIP_SCALE}, {-0.71f * SHIP_SCALE, -0.57f * SHIP_SCALE}, }; static const struct v2 tail[] = { {-0.43f * SHIP_SCALE, -0.20f * SHIP_SCALE}, {-0.70f * SHIP_SCALE, +0.00f * SHIP_SCALE}, {-0.43f * SHIP_SCALE, +0.20f * SHIP_SCALE}, };
Finally to our first data structure, I'm
guessing v2
is "vector 2". The array of XY values
called ship and tail probably refer to the
lines making up the player on-screen, it may be more obvious in
the explicit calls to draw these objects but it seems like the
points must be drawn in some fixed origin and moved about the
screen independently.
Also suggestive is the scaling factor SHIP_SCALE
—
want about a gigantic ship? No problem:
/* 7-segment font for digits */ #define FONT_SX 0.015f #define FONT_SY 0.025f static const struct v2 segv[] = { {0.0f*FONT_SX, 1.0f*FONT_SY}, {1.0f*FONT_SX, 1.0f*FONT_SY}, {0.0f*FONT_SX, 0.5f*FONT_SY}, {0.0f*FONT_SX, 1.0f*FONT_SY}, {1.0f*FONT_SX, 0.5f*FONT_SY}, {1.0f*FONT_SX, 1.0f*FONT_SY}, {0.0f*FONT_SX, 0.5f*FONT_SY}, {1.0f*FONT_SX, 0.5f*FONT_SY}, {0.0f*FONT_SX, 0.0f*FONT_SY}, {0.0f*FONT_SX, 0.5f*FONT_SY}, {1.0f*FONT_SX, 0.0f*FONT_SY}, {1.0f*FONT_SX, 0.5f*FONT_SY}, {0.0f*FONT_SX, 0.0f*FONT_SY}, {1.0f*FONT_SX, 0.0f*FONT_SY}, }; static const char seg7[] = { 0x77, 0x24, 0x5d, 0x6d, 0x2e, 0x6b, 0x7b, 0x25, 0x7f, 0x6f };
I think these XY values represent start-end points for the
lines in
a seven-segment
display. It wasn't at all apparent to me what
the seg7
array was until viewing the elements in
binary. I'm pretty sure the array will be used to mask out the
lines to be drawn when a score is converted from a numeric value
into the on-screen display.
0x77 | 1110111 |
|
||
0x24 | 0100100 |
|
||
0x5d | 1011101 |
|
||
0x6d | 1101101 |
|
static double uepoch(void) { FILETIME ft; GetSystemTimeAsFileTime(&ft); unsigned long long hi = ft.dwHighDateTime; unsigned long long lo = ft.dwLowDateTime; return ((hi<<32 | lo)/10 - 11644473600000000) / 1e6; } static unsigned long long rng; static unsigned long rand32(void) { rng = rng*0x7c3c3267d015ceb5 + 1; unsigned long r = rng>>32 & 0xffffffff; r ^= r>>16; r *= 0x60857ba9; r &= 0xffffffff; r ^= r>>16; return r; } static float randu(void) { return rand32() / 4294967296.0f; }
Grab the system time which for reasons unknown to me comes in two parts (low-order and high-order), shift the high-order parts before OR-ing them, for one big number. The number 11644473600000000 is apparently the difference between Windows epoch time (01/01/1601) and Unix epoch time (01/01/1970) with some adjustments for nanoseconds vs. seconds etc.
rand32
is probably best taken on faith. I think
this is
a permuted
congruential generator, also
note RANDU. 4,294,967,296
is of course 232.
struct tf { float c, s, tx, ty; }; static struct tf tf(float a, float tx, float ty) { struct tf tf = { .c = cosf(a), .s = sinf(a), .tx = tx, .ty = ty, }; return tf; } static struct v2 tf_apply(struct tf tf, struct v2 v) { struct v2 r = { tf.tx + v.x*tf.c - v.y*tf.s, tf.ty + v.x*tf.s + v.y*tf.c, }; return r; }
Aha! This looks a bit like the drawing offsets I speculated
about. Going out on a limb I would guess that tf
might be "transform" and holds a cosine, sine, x and y
value. The function tf
returns the struct based on
an argument a
(presumably
angle). tf_apply
is the most interesting part
though, it takes a "transform" and a vector 2 (an XY position,
I would guess) and returns a new vector 2 (another position).
Embarrassingly, I didn't immediately recognize the standard rotation transformation despite having implemented it myself at least twice. In my defense I am more accustomed to the matrix form:
static int lltostr(char *buf, long long n) { int len = n ? 0 : 1; for (long long x = n; x; x /= 10) { len++; } buf[len] = 0; for (int i = len - 1; i >= 0; i--) { buf[i] = '0' + n%10; n /= 10; } return len; }
long long to string - count up the digits (by tens) before allocating a buffer of that length. Fill each digit position with the ASCII value of the appropriate single-digit value via moduluo arithmetic. Chop off a tens place as the loop progresses.
static int g_nlines; static struct { GLubyte r, g, b, a; GLfloat x, y; } g_linebuf[1<<16]; static int g_npoints; static struct { GLubyte r, g, b, a; GLfloat x, y; } g_pointbuf[1<<10];
Here we reach some of the OpenGL specifics. The struct isn't too interesting and looks similar to data already covered. The GL- datatypes seem obvious enough, though I did double check the documentation.
One thing to note is the curious duplication between lines and
points. I'm not sure yet what to make of that, but there are far
fewer points expected (1024 compared 65536). I wonder if points
are used for the player shots. To test this out I both turned
this way down and increased the firing rate
(SHOT_COOLDOWN
). It appears an insufficient points
array allocation results in a number of oddities in drawing to
the screen. While nothing crashed, there were instances where
the ship didn't appear to be firing and where asteroids failed
to render and would appear mid-screen.
static const signed char toroid[][2] = { {+0, +0}, {-1, +0}, {+1, +0}, {+0, -1}, {+0, +1} };
A two dimensional array called toroid. Probably related to the world "wrapping" but I don't yet see what it is for.
/* Push line segment onto rendering buffer. */ static void g_line(struct v2 a, struct v2 b, uint32_t color) { int i = g_nlines; g_nlines += 2; g_linebuf[i+0].r = color >> 16; g_linebuf[i+0].g = color >> 8; g_linebuf[i+0].b = color >> 0; g_linebuf[i+0].a = color >> 24; g_linebuf[i+0].x = 2*a.x - 1; g_linebuf[i+0].y = 2*a.y - 1; g_linebuf[i+1].r = color >> 16; g_linebuf[i+1].g = color >> 8; g_linebuf[i+1].b = color >> 0; g_linebuf[i+1].a = color >> 24; g_linebuf[i+1].x = 2*b.x - 1; g_linebuf[i+1].y = 2*b.y - 1; }
Seems pretty straight-forward, take two XY positions (vector
twos) and a color and push it into the buffer of lines. One
interesting thing to note is how the index position is global
variable g_nlines
.
/* Push toroid-wrapped line segment onto the rendering buffer. */ static void g_wline(struct v2 a, struct v2 b, uint32_t color) { for (int i = 0; i < 5; i++) { float tx = toroid[i][0]; float ty = toroid[i][1]; struct v2 ta = {a.x+tx, a.y+ty}; struct v2 tb = {b.x+tx, b.y+ty}; g_line(ta, tb, color); } }
Aha — we find our toroid here. For each possible wrapping (I want to call them something like origin, N-E-S-W but I'm sure that would drive people crazy) offset the ends of a given line defined by two XY positions and a color.
+0,+1 | ||
-1,+0 | +0,+0 | +1,+0 |
+0,-1 |
static void g_wlinestrip(const struct v2 *v, int n, struct tf tf, uint32_t color) { struct v2 a = tf_apply(tf, v[0]); for (int i = 1; i < n; i++) { struct v2 b = tf_apply(tf, v[i]); g_wline(a, b, color); a = b; } } static void g_wlineloop(const struct v2 *v, int n, struct tf tf, uint32_t color) { g_wlinestrip(v, n, tf, color); g_wline(tf_apply(tf, v[n-1]), tf_apply(tf, v[0]), color); }
Here it seems we finally answer my open question about drawing objects. Loop through an array of XY positions, applying a given transformation and pushing the result to the line buffer.
The final call in g_wlineloop
looks like it is used
to close the form, or completing a shape from last point back to
first. Funnily enough I did the same sort of thing
when plotting
polygons.
static void g_render(void) { glClear(GL_COLOR_BUFFER_BIT); glInterleavedArrays(GL_C4UB_V2F, 0, g_linebuf); glDrawArrays(GL_LINES, 0, g_nlines); g_nlines = 0; glInterleavedArrays(GL_C4UB_V2F, 0, g_pointbuf); glDrawArrays(GL_POINTS, 0, g_npoints); g_npoints = 0; }
Here it seems we just call a number of OpenGL
APIs. Unsurprisingly, glClear
clears a
buffer. glInterleavedArrays
appears to take the array of two-dimensional lines, the second
argument (0) indicates the array elements are stored
consecutively. As far as I can tell from reading online this
call merely indicates where to find data however no data is
actually accessed until the draw call. Which is exactly what
happens next
with glDrawArrays
. With
the lines drawn the number of lines is reset to 0. The exact
same thing happens again with the points.
static void g_point(float x, float y, uint32_t color) { int i = g_npoints++; g_pointbuf[i].r = color >> 16; g_pointbuf[i].g = color >> 8; g_pointbuf[i].b = color >> 0; g_pointbuf[i].a = color >> 24; g_pointbuf[i].x = 2*x - 1; g_pointbuf[i].y = 2*y - 1; } static void g_wpoint(float x, float y, uint32_t color) { for (int i = 0; i < 5; i++) { g_point(toroid[i][0] + x, toroid[i][1] + y, color); } }
While the point drawing and wrapping looks largely similar to
the line drawing I am curious about the multiplier on the x and
y position. While it is the same as the line drawing I find I
can focus more on it here because of how familiar the rest
is. In an effort to understand what it is for I tried removing
it. The effect of the change is odd, the player's shot is now
split across three different positions:
I think the appearance of what looks like multiple positions is probably an effect of the wrapping. More than anything the doubling of both x and y values seems like a scaling factor or offset, but I don't know what for yet.
struct { double last; long level; float transition; long long score; int lives; #define I_TURNL (1<<0) #define I_TURNR (1<<1) #define I_THRUST (1<<2) #define I_FIRE (1<<3) int controls; float px, py, pa; float pdx, pdy, pda; struct asteroid { float x, y, a; float dx, dy, da; struct v2 v[16]; short n; short kind; float shot_r2; // shot hit radius^2 float ship_r2; // ship hit radius^2 } asteroids[1024]; int nasteroids; struct shot { float x, y; float dx, dy; float ttl; } shots[64]; int nshots; float cooldown; struct debris { float x, y; float dx, dy, da; float age; uint32_t color; struct v2 v[2]; } debris[1024]; int ndebris; } game;
A big bag of state from the looks of it. I've no idea why
turn/thrust/fire are #define
-ed in here. One
interesting thing to note that makes sense in retrospect but I
hadn't before paid attention to; the "bounding box" or contact
area for the asteroid is a circle. Presumably it is a fixed size
circle based on the three types we saw enumerated earlier, but
consequently if you look closely while shooting especially
craggy asteroids sometimes shows they blow up before the shot
contacts them.
static int game_asteroid(enum asteroid_size kind) { if (game.nasteroids == COUNTOF(game.asteroids)) return -1; struct asteroid *a = game.asteroids + game.nasteroids; float dx, dy; do { a->x = randu(); a->y = randu(); dx = a->x - 0.5f; dy = a->y - 0.5f; } while (dx*dx + dy*dy < 0.1f); a->dx = 0.1f * (2*randu() - 1); a->dy = 0.1f * (2*randu() - 1); a->a = 2 * PI * randu(); a->da = PI*(2*randu() - 1); int n = 0; float min = 0; float max = 0; switch (kind) { case A0: n = 16; max = ASTEROID0_MAX; min = ASTEROID0_MIN; break; case A1: n = 12; max = ASTEROID1_MAX; min = ASTEROID1_MIN; break; case A2: n = 8; max = ASTEROID2_MAX; min = ASTEROID2_MIN; break; } for (int i = 0; i < n; i++) { float t = 2*PI * (i - 1) / (float)n; float r = randu()*(max - min) + min; a->v[i].x = r * cosf(t); a->v[i].y = r * sinf(t); } a->n = n; a->kind = kind; // Make hit radius favor player since it's imprecise a->shot_r2 = max*max; a->ship_r2 = (max+min)*(max+min)/4; return game.nasteroids++; }
After checking that we haven't met the required number of asteroids; offset into the array of asteroids inside the above game struct. Populate the asteroid position with random data until dx2 + dy2 < 0.1 . Then assign the asteroid's dx and dy values with more random data.
I'm not positive but I think this, paired with the wrapping around the edges, achieves a random layout with an affordance for the player's start position (so you don't begin the game with an asteroid on top of you).
dx and dy define the asteroids movement, so 1 would be a straight line for either of them and 1 for both of them is a diagonal line (and very fast!). da is the rotational speed of the asteroid, let's try cranking it way up!
The kind of asteroid determines how craggy they are, the switch statement sets a value n which is looped over to define vertices for the asteroid's exterior.
Finally, each asteroid is assigned a shot hit radius and ship
hit radius. Interestingly the ship hit radius is almost half the
size of the shot hit radius, which explains the comment about
favoring the player.
static void game_new_level(void) { game.px = game.py = 0.5f; game.pdx = game.pdy = 0.0f; game.nshots = 0; game.ndebris = 0; game.cooldown = 0; game.transition = 0; game.lives = 1; game.nasteroids = 0; for (int i = 0; i < game.level; i++) { game_asteroid(A0); } }
New level resets the different values for debris, position etc.
Each time you advance levels the number of asteroids is increased.
static void game_init(int size) { glEnable(GL_LINE_SMOOTH); glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); glEnable(GL_POINT_SMOOTH); glHint(GL_POINT_SMOOTH_HINT, GL_NICEST); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glLineWidth(2e-3f * size); glPointSize(4e-3f * size); rng += uepoch() * 1e6; game.level = INIT_COUNT; game.last = uepoch(); game.pa = PI/2; game.pda = 0.0f; game.controls = 0; game.score = 0; game_new_level(); }
The calls to enable and hint turn on anti-aliasing. I think they
really do mean "hint" though, I removed the calls and could not
notice a difference. The calls to enable blending on the other
hand are very noticeable, blending enables the alpha channel and
determines whether debris fades away or just blinks out after a
span of time. glClearColor
determines the color to
which the screen is cleared to (clear is invoked inside
render). Of course, in RGBA (0.1,0.1,0.1,1.0) appears like
this: dark!
The random number generator is set based off the time, which percolates through the different calls for random data like asteroid position.
static void game_down(int control) { if (game.controls & control) return; game.controls |= control; switch (control) { case I_TURNL: game.pda += SHIP_TURN_RATE; break; case I_TURNR: game.pda -= SHIP_TURN_RATE; break; case I_THRUST: break; case I_FIRE: break; } } static void game_up(int control) { if (!(game.controls & control)) return; game.controls ^= control; switch (control) { case I_TURNL: game.pda -= SHIP_TURN_RATE; break; case I_TURNR: game.pda += SHIP_TURN_RATE; break; case I_THRUST: break; case I_FIRE: break; } }
Game control is guarded with complementary AND/OR on (what I assume is) keypress. Otherwise turning increments or decrements the player's rotational speed.
static void game_debris(struct v2 *v, float x, float y, float dx, float dy, uint32_t c) { if (game.ndebris < COUNTOF(game.debris)) { int i = game.ndebris++; game.debris[i].x = x; game.debris[i].y = y; game.debris[i].dx = dx; game.debris[i].dy = dy; game.debris[i].da = 2*PI*(2*randu() - 1); game.debris[i].age = 0; game.debris[i].color = c & 0xffffff; game.debris[i].v[0] = v[0]; game.debris[i].v[1] = v[1]; } }
At this point this looks familiar, index an array of vector data with updates for the game state to reflect position, direction etc. but this time of debris.
static void game_destroy_asteroid(int n) { struct asteroid *a = game.asteroids + n; struct tf t = tf(a->a, 0, 0); for (int i = 0; i < a->n; i++) { int j = (i + 1)%a->n; struct v2 v[] = { tf_apply(t, a->v[i]), tf_apply(t, a->v[j]), }; float mx = (v[0].x + v[1].x) / 2; float my = (v[0].y + v[1].y) / 2; v[0].x -= mx; v[1].x -= mx; v[0].y -= my; v[1].y -= my; float dx = a->dx + mx*randu(); float dy = a->dy + my*randu(); game_debris(v, a->x+mx, a->y+my, dx, dy, C_ASTEROID); } float x = a->x; float y = a->y; enum asteroid_size kind = a->kind; game.asteroids[n] = game.asteroids[--game.nasteroids]; switch (kind) { case A0: game.score += ASTEROID0_SCORE; break; case A1: game.score += ASTEROID1_SCORE; break; case A2: game.score += ASTEROID2_SCORE; break; } if (kind != A2) { int c = 1 + rand32()%2; for (int i = 0; i < c; i++) { int n = game_asteroid(kind + 1); if (n >= 0) { game.asteroids[n].x = x; game.asteroids[n].y = y; } } } }
This is interesting. Index into the asteroids array, pull the
rotation angle into a transformation, iterate over the points
(line segments?) comprising the asteroid, applying the
rotation. Construct values mx
and my
(I wonder if this is "movement" or similar) and create debris
from this "movement" of the asteroid and a bit of
randomization.
Asteroids are reduced along the order of sizes enumerated previously, if the asteroid isn't the smallest possible the newly reduced asteroid is positioned at the same location as the destroyed, larger asteroid. The number of pieces an asteroid is broken into is slightly variable.
static void game_step(void) { double now = uepoch(); float dt = now - game.last; if (dt > TIME_STEP_MIN) dt = TIME_STEP_MIN; game.last = now; if (!game.lives || !game.nasteroids) { game.transition += dt; } if (!game.lives && game.transition > LEVEL_DELAY) { game.score /= 2; game_new_level(); } else if (!game.nasteroids && game.transition > LEVEL_DELAY) { game.score += game.level * 100; game.level++; game_new_level(); }
Finally we find the most complicated part of the program, the function responsible for advancing forward the progress of each game cycle.
Check that the player hasn't died and that there are remaining asteroids. Otherwise transition to the next level or respawn.
Respawning halves the player score while advancing the level gives a bonus based on which level it is.
game.pa = fmodf(game.pa + dt*game.pda + 2*PI, 2*PI); if (game.controls & I_THRUST) { float c = cosf(game.pa); float s = sinf(game.pa); game.pdx += dt*c*SHIP_ACCEL; game.pdy += dt*s*SHIP_ACCEL; /* thruster fire trail */ if (randu() < 0.75f) { float f = SHIP_SCALE*0.15f; struct v2 v[] = { {(2*randu() - 1)*f, (2*randu() - 1)*f}, {(2*randu() - 1)*f, (2*randu() - 1)*f}, }; float x = game.px + c*ship[3].x; float y = game.py + s*ship[3].x; game_debris(v, x, y, -c*0.1f, -s*0.1f, C_FIRE); } } game.px = fmodf(game.px + dt*game.pdx + 1, 1); game.py = fmodf(game.py + dt*game.pdy + 1, 1); game.pdx *= SHIP_DAMPEN; game.pdy *= SHIP_DAMPEN;
fmodf
calculates the floating-point remainder. Update the player angle
with respect to the current time, acceleration and previous
angle.
If the player is using the thruster update the angle of movement
along with player velocity. Draw a neat little thruster effect
using the game_debris
function along with a bit of
randomization.
Apply dampening to slow player movement, regarless of thrusters.
if ((game.controls & I_FIRE) && game.nshots < COUNTOF(game.shots) && game.cooldown <= 0) { int i = game.nshots++; float c = cosf(game.pa); float s = sinf(game.pa); game.shots[i].x = SHIP_SCALE*c+game.px; game.shots[i].y = SHIP_SCALE*s+game.py; game.shots[i].dx = game.pdx + c*SHOT_SPEED; game.shots[i].dy = game.pdy + s*SHOT_SPEED; game.shots[i].ttl = SHOT_TTL; game.cooldown = SHOT_COOLDOWN; } else if (game.cooldown > 0) { game.cooldown -= dt; } for (int i = 0; i < game.nshots; i++) { struct shot *s = game.shots + i; if ((s->ttl -= dt) < 0) { // TODO: hit detection for final partial step game.shots[i--] = game.shots[--game.nshots]; } else { s->x = fmodf(s->x + dt*s->dx + 1, 1); s->y = fmodf(s->y + dt*s->dy + 1, 1); } }
Rate limit the player's firing. Write out to the shots array the new shot position, angle, speed. Set the inter-shot cooldown for rate limiting.
Age out shots that have expired their TTL. Update shot XY position based on the advance of the direction vector tracked on each shot struct.
Reading this makes me think of the sorts of "cheat codes" you might enable in old video games for infinite ammunition or double speed. I couldn't help trying a few cheat codes of my own here, extending the shot TTL, increasing turning rate, and increasing firing rate:
for (int i = 0; i < game.nasteroids; i++) { struct asteroid *a = game.asteroids + i; a->x = fmodf(a->x + dt*a->dx + 1, 1); a->y = fmodf(a->y + dt*a->dy + 1, 1); a->a = fmodf(a->a + dt*a->da, 360); } // TODO: precise hit detection for (int i = 0; i < game.nshots; i++) { struct shot *s = game.shots + i; for (int j = 0; j < game.nasteroids; j++) { struct asteroid *a = game.asteroids + j; float dx = s->x - a->x; float dy = s->y - a->y; if (dx*dx + dy*dy < a->shot_r2) { game.shots[i--] = game.shots[--game.nshots]; game_destroy_asteroid(j--); break; } } }
Iterate each asteroid and update the position and angle.
Loop over each shot and over each asteroid per-shot to test whether the position of each overlaps within the bounds of the shot radius. If they do fall within the bounds the asteroid is destroyed.
for (int i = 0; i < game.ndebris; i++) { if ((game.debris[i].age += dt) > DEBRIS_TTL) { game.debris[i--] = game.debris[--game.ndebris]; } } // TODO: precise hit detection struct tf t = tf(game.pa, game.px, game.py); for (int i = 0; game.lives && i < COUNTOF(ship); i++) { struct v2 p = tf_apply(t, ship[i]); for (int j = 0; j < game.nasteroids; j++) { struct asteroid *a = game.asteroids + j; float dx = p.x - a->x; float dy = p.y - a->y; if (dx*dx + dy*dy < a->ship_r2) { game.lives = 0; for (int i = 0; i < 256; i++) { float s = 0.01f; struct v2 v[] = { {s*(randu()*2 - 1), s*(randu()*2 - 1)}, {s*(randu()*2 - 1), s*(randu()*2 - 1)}, }; float r = 0.25f*randu(); float a = 2*PI*randu(); float dx = game.pdx/2 + r*cosf(a); float dy = game.pdy/2 + r*sinf(a); uint32_t color = randu() < 0.7f ? C_SHIP : C_FIRE; game_debris(v, game.px, game.py, dx, dy, color); } break; } } } }
Clear debris as it ages out.
Given the player's orientation, iterate over each asteroid to test their position against the ship position. If the positions overlap within the bounds of the ship strike radius set lives to zero and draw debris.
static void game_render(void) { for (int i = 0; i < game.nasteroids; i++) { struct asteroid *a = game.asteroids + i; g_wlineloop(a->v, a->n, tf(a->a, a->x, a->y), C_ASTEROID); } for (int i = 0; i < game.nshots; i++) { g_wpoint(game.shots[i].x, game.shots[i].y, C_SHOT); } if (game.lives) { struct tf ship_tf = tf(game.pa, game.px, game.py); g_wlineloop(ship, COUNTOF(ship), ship_tf, C_SHIP); if ((game.controls & I_THRUST) && randu() < 0.5f) { g_wlinestrip(tail, COUNTOF(tail), ship_tf, C_THRUST); } } else { } for (int i = 0; i < game.ndebris; i++) { struct debris *d = game.debris + i; float dt = d->age; struct tf t = tf(dt*d->da, d->x + dt*d->dx, d->y + dt*d->dy); uint32_t alpha = 255 * (1 - d->age/DEBRIS_TTL); g_wlinestrip(d->v, COUNTOF(d->v), t, d->color | alpha<<24); } char score[32]; int scorelen = lltostr(score, game.score); for (int i = 0; i < scorelen; i++) { float pad = 0.01f; struct tf t = tf(0, pad + i*FONT_SY, 1 - pad - FONT_SY); int segs = seg7[score[i] - '0']; for (int s = 0; s < 7; s++) { if (segs & 1<<s) { struct v2 a = tf_apply(t, segv[s*2+0]); struct v2 b = tf_apply(t, segv[s*2+1]); g_line(a, b, C_SCORE); } } } g_render(); }
Rendering the game! g_wlineloop
draws each asteroid
given the orientation information tracked in each struct. Draw
each shot as a point. If the player hasn't died, draw the
ship. If the player is using the thruster, draw the "fire"
effect.
When drawing the debris it is interesting to see the alpha value is dependent on the age over the TTL, which is how the fading is achieved.
The score is written using the previously
explained lltostr
function. The seg7
array reappears and we can verify earlier speculation about the
format and use. Lines are drawn for each segment of each digit.
static BOOL win32_opengl_initialized; static int win32_opengl_size; static void win32_opengl_init(HDC hdc) { PIXELFORMATDESCRIPTOR pdf = { .nSize = sizeof(pdf), .nVersion = 1, .dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER, .iPixelType = PFD_TYPE_RGBA, .cColorBits = 32, .cDepthBits = 24, .cStencilBits = 8, .iLayerType = PFD_MAIN_PLANE, }; SetPixelFormat(hdc, ChoosePixelFormat(hdc, &pdf), &pdf); HGLRC old = wglCreateContext(hdc); wglMakeCurrent(hdc, old); win32_opengl_initialized = TRUE; }
A few remaining pieces of OpenGL
initialization. PIXELFORMATDESCRIPTOR
and
SetPixelFormat
seem to be for configuring pixel information such as color depth
and double buffering. Double buffering
is, of
course, "painting" to memory before swapping the "painted"
scene onto the screen to reduce flicker.
wglMakeCurrent
sets the context in which drawing
operations happen (this all seems pretty boiler-plate to me).
static LRESULT CALLBACK win32_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { switch (msg) { case WM_CREATE: win32_opengl_init(GetDC(hwnd)); game_init(win32_opengl_size); break; case WM_KEYUP: switch (wparam) { case VK_LEFT: game_up(I_TURNL); break; case VK_RIGHT: game_up(I_TURNR); break; case VK_UP: game_up(I_THRUST); break; case VK_SPACE: game_up(I_FIRE); break; } break; case WM_KEYDOWN: if (lparam & 0x40000000) break; switch (wparam) { case VK_LEFT: game_down(I_TURNL); break; case VK_RIGHT: game_down(I_TURNR); break; case VK_UP: game_down(I_THRUST); break; case VK_SPACE: game_down(I_FIRE); break; } break; case WM_CLOSE: case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hwnd, msg, wparam, lparam); } return 0; }
Input handling! Obviously the bulk of this is a boring old
switch statement for handling the arrow keys and spacebar (for
movement and firing, respectively). The actual message handling
is defined by
the window
proc interface, in particular some oddities like if
(lparam & 0x40000000) break;
are a result of the
following from
the documentation:
30 The previous key state. The value is 1 if the key is down before the message is sent, or it is zero if the key is up.
Which is a non-obvious way of testing the 30th bit to see if the key was down ( 0x40000000 ≡ 1000000000000000000000000000000 (base-2)).
The default call
to DefWindowProc
is more boiler-plate to ensure all events are handled and comes
nearly directly from the documentation.
static HWND win32_window_init(void) { const char *title = "Asteroids"; WNDCLASS wndclass = { .style = CS_OWNDC, .lpfnWndProc = win32_wndproc, .lpszClassName = "gl", .hCursor = LoadCursor(0, IDC_ARROW), .hIcon = LoadIcon(GetModuleHandle(0), MAKEINTRESOURCE(1)), }; RegisterClass(&wndclass); int w = GetSystemMetrics(SM_CXSCREEN); int h = GetSystemMetrics(SM_CYSCREEN); int size = win32_opengl_size = (h < w ? h : w) - 100; int x = (w - size) / 2; int y = (h - size) / 2; DWORD style = WS_OVERLAPPED | WS_VISIBLE | WS_MINIMIZEBOX | WS_SYSMENU; return CreateWindow("gl", title, style, x, y, size, size, 0, 0, 0, 0); }
Windows™ window initialization including giving the window a
title, an icon, registering the previous input
handlers. GetModuleHandle(0)
is how you get a
handle on the current process.
Set a window size for the application along with some styles to define the behavior of the application. My understanding this is how you might configure things like launching in fullscreen, or with limited window controls (minimize, maximize, etc.).
CreateWindow
is also responsible for sending the WM_CREATE
message that is handled above to perform initialization.
int main(void) { HWND wnd = win32_window_init(); HDC hdc = GetDC(wnd); for (;;) { MSG msg; while (PeekMessage(&msg, 0, 0, 0, TRUE)) { if (msg.message == WM_QUIT) { ExitProcess(0); } TranslateMessage(&msg); DispatchMessage(&msg); } if (win32_opengl_initialized) { game_step(); game_render(); SwapBuffers(hdc); } } return 0; }
Finally we reach the end and the top-most calling code. Initialize our window, get a device context to draw inside, handle input events and once things are initialized start the game loop.
I've spent quite a while at this point picking through this game to understand it and how it leverages native Windows APIs. Even more impressive to me, I've been building and running it on Linux under Wine without issue. Considering how much time I spend in a given week tracking down issues caused by transitive dependencies and obscure tooling it is a breath of fresh air to avoid all of it.
While I don't yet feel as though I could build an entire application from scratch I can't help but think of this as a logical step forward based on my own past experiences. After all, what is the argument against doing it yourself?