sunlight

Xonix: Step 1

Introduction

In this brief interlude, we will cover:

  • The creation of a sample game, Xonix, starting from the framework we developed in the tutorial.
  • Sprites as classes.

Up View Code Download Code Next

Game Design

Xonix is an old game, which I remember from a DOS version from 1984. Numerous versions exist, and so it's quite possible that you've seen it before.

It is played in a 2-dimensional arena, in which balls bounce around. The player controls a ball that can move at will around the edges of this arena. This player-controlled ball can also venture into the arena, leaving a trail behind it. When the ball reaches the edge of the arena again, areas inside the trail that do not contain other balls are filled in. If another ball hits the player's trail, the player loses a life. The objective is to fill in 75% of the arena.

If that's not clear, it's quite impossible to describe further, like most simple puzzle games. You'll just have to play it and see...

Adding Controls

First, we need a new GUID for our application:

// {5447976E-1120-4762-87E5-61ACC539746A}
GUID g_guidApp = 
{ 0x5447976e, 0x1120, 0x4762, { 0x87, 0xe5, 0x61, 0xac, 0xc5, 0x39, 0x74, 0x6a } };

Xonix requires two-dimensional movement. We must therefore add a few more controls to our game to give it this capability. We will also add music on/off controls and configuration:

DIACTION g_rgActions[] =
{
    // Genre-defined virtual axes
    { ACTIONS_LEFTRIGHT,    DIAXIS_ARCADES_LATERAL,             0,  "Left/Right" },
    { ACTIONS_UPDOWN,       DIAXIS_ARCADES_MOVE,                0,  "Up/Down" },

    // Genre-defined virtual buttons
    { ACTIONS_LEFT,         DIBUTTON_ARCADES_LEFT_LINK,         0,  "Left" },
    { ACTIONS_RIGHT,        DIBUTTON_ARCADES_RIGHT_LINK,        0,  "Right" },
    { ACTIONS_UP,           DIBUTTON_ARCADES_FORWARD_LINK,      0,  "Up" },
    { ACTIONS_DOWN,         DIBUTTON_ARCADES_BACK_LINK,         0,  "Down" },
    { ACTIONS_PAUSE,        DIBUTTON_ARCADES_PAUSE,             0,  "Pause" },

    // Keys
    { ACTIONS_LEFT,         DIKEYBOARD_LEFT,                    0,  "Left" },
    { ACTIONS_LEFT,         DIKEYBOARD_NUMPAD4,                 0,  "Left" },
    { ACTIONS_RIGHT,        DIKEYBOARD_RIGHT,                   0,  "Right" },
    { ACTIONS_RIGHT,        DIKEYBOARD_NUMPAD6,                 0,  "Right" },
    { ACTIONS_UP,           DIKEYBOARD_UP,                      0,  "Up" },
    { ACTIONS_UP,           DIKEYBOARD_NUMPAD8,                 0,  "Up" },
    { ACTIONS_DOWN,         DIKEYBOARD_DOWN,                    0,  "Down" },
    { ACTIONS_DOWN,         DIKEYBOARD_NUMPAD2,                 0,  "Down" },
    { ACTIONS_PAUSE,        DIKEYBOARD_P,           DIA_APPFIXED,   "Pause" },
    { ACTIONS_MUSIC,        DIKEYBOARD_M,           DIA_APPFIXED,   "Music on/off" },
    { ACTIONS_HELP,         DIKEYBOARD_F1,          DIA_APPFIXED,   "Help" },
    { ACTIONS_CONFIGURE,    DIKEYBOARD_F2,          DIA_APPFIXED,   "Configure" },
    { ACTIONS_QUIT,         DIKEYBOARD_ESCAPE,      DIA_APPFIXED,   "Quit" },
};

We handle these new actions in much the same way we have handled others before.

Game Logic

Since game logic tends to be environment-independent, I won't deal with much of it here. It is sufficient to note that the arena is defined as a grid:

BYTE    Grid[YBLOCKS][XBLOCKS];

It is, however, interesting to look at the filling technique. This uses GDI's ExtFloodFill to fill in areas. First, the grid is copied into a black-and-white bitmap:

    // Create a black-and-white bitmap with each bit representing
    //  a block on the screen: white=full, black=empty
    HBITMAP hBitmap;
    LPBYTE  pBitmap = new BYTE[XBLOCKS * YBLOCKS / 8];

    memset(pBitmap, 0, XBLOCKS * YBLOCKS / 8);
    for (y = 0; y < YBLOCKS; y++)
    {
        for (x = 0; x < XBLOCKS; )
        {
            for (nMask = 0x80; nMask; nMask >>= 1, x++)
            {
                if (Grid[y][x] != GRID_EMPTY)
                    pBitmap[(y * XBLOCKS + x) / 8] |= nMask;
            }
        }
    }

Next, ExtFloodFill is used to fill this bitmap in with white everywhere there is a ball. The result is black only in areas where the grid is empty but should be filled (due to there being no ball in that area).

    for (i = 0; i < g_nBalls; i++)
        ExtFloodFill(hDC, (int)g_spriteBalls[i]->m_x / BLOCKSIZE,
	    (int)g_spriteBalls[i]->m_y / BLOCKSIZE,
            0, FLOODFILLSURFACE);

We then update the grid from the bitmap.

    int nFill = 0;
    for (y = 0; y < YBLOCKS; y++)
    {
        for (x = 0; x < XBLOCKS; )
        {
            for (nMask = 0x80; nMask; nMask >>= 1, x++)
            {
                if ((pBitmap[(y * XBLOCKS + x) / 8] & nMask) == 0)
                    Grid[y][x] = GRID_FULL;
                if (Grid[y][x] == GRID_FULL)
                    nFill++;
            }
        }
    }

Drawing the Grid

The grid consists of 64x46 10x10 pixel squares. Using a trivial algorithm for drawing the grid, each filled square would be drawn separately. However, if the grid was to be filled to 75%, this means over 2,000 calls to BackBlt will be made - which is somewhat slow.

In order to speed things up, a novel algorithm for reducing this grid to larger and fewer rectangles is used. For each filled in grid item, we will attempt to extend the rectangle as far as possible to the right, and draw all those at once. If it is not possible to do so, we will extend the rectangle vertically. This means grid drawing becomes far swifter. In the initial state, 424 grid blocks are filled in, but this requires only 88 calls to BackBlt - and it tends to get better as more of the grid is filled. More efficient algorithms exist, but this one is fast and easy to understand.

    for (y = 0; y < YBLOCKS; y++)
    {
        for (x = 0; x < XBLOCKS; x++)
        {
            if (DrawGrid[y][x] == GRID_EMPTY)
                continue;

            // Extend the drawing rectangle as far as possible
	    //  in the horizontal direction.
            for (i = 1; ((x + i) < XBLOCKS) && 
		    (DrawGrid[y][x + i] == DrawGrid[y][x]); i++)
                DrawGrid[y][x + i] = GRID_EMPTY;
            if (i > 1)
            {
                SetRect(&r, x * BLOCKSIZE, y * BLOCKSIZE, 
			(x + i) * BLOCKSIZE, (y + 1) * BLOCKSIZE);
            }
            else
            {
                // Rectangle was only 1 block wide, so extend the rectangle 
                //  as far as possible in the vertical direction.
                for (i = 1; ((y + i) < YBLOCKS) && 
			(DrawGrid[y + i][x] == DrawGrid[y][x]); i++)
                    DrawGrid[y + i][x] = GRID_EMPTY;
                SetRect(&r, x * BLOCKSIZE, y * BLOCKSIZE, 
			(x + 1) * BLOCKSIZE, (y + i) * BLOCKSIZE);
            }

            switch (DrawGrid[y][x])
            {
            case GRID_EMPTY:
                break;

            case GRID_FULL:
                BackBlt(x * BLOCKSIZE, y * BLOCKSIZE, lpImages, 
			&r, DDBLTFAST_NOCOLORKEY);
                break;

            case GRID_MID:
                r.top += MIDTOP;
                r.bottom += MIDTOP;
                BackBlt(x * BLOCKSIZE, y * BLOCKSIZE, lpImages, 
			&r, DDBLTFAST_NOCOLORKEY);
                break;
            }

            // Note that we don't have to reset the current position, since 
            //  it will not again be considered.
        }
    }

Sprite Classes

Each of the sprites used in this application (the player and the balls) shares attributes with each other: they all have a position, a velocity, a source surface and a source rectangle on that surface. The various kinds of sprites are merely specialisations of these sprites. It therefore makes sense to make a CSprite base class that defines common sprite actions, then deriving classes from this base class.

Some of the game logic then appears in the derived classes; for example, the balls will bounce inside their override of Move(), and will also detect collisions.

Finishing Up

All that remains is to load and release our surfaces, and to handle the various input actions:

     if (g_bDoConfig)
    {
        StopAllSound();
        g_bMusicPlaying = false;
        g_bDoConfig = false;
        ConfigureInput();
        // Music came back on when we were reactivated...
    }
    
    if (!g_bPaused)
    {
        // Handle movement
        if (g_bUp)
            g_spritePlayer->m_nNextDirection = CPlayerSprite::Direction::UP;
        if (g_bDown)
            g_spritePlayer->m_nNextDirection = CPlayerSprite::Direction::DOWN;
        if (g_bLeft)
            g_spritePlayer->m_nNextDirection = CPlayerSprite::Direction::LEFT;
        if (g_bRight)
            g_spritePlayer->m_nNextDirection = CPlayerSprite::Direction::RIGHT;

        UpdateGameState();
    }

We're done. A few hundred lines of code (almost all of it game logic) was all that was required to build a new game. This should give you some idea of the power our framework offers.

In the next section, we'll go on to polish our application, adding a splash screen, status bar and frame rate counter. 

 

Copyright © David McCabe, 1998 - 2001. All rights reserved.

You will need to download and install the m-math control to display any equations on this Web site. Without this control, you will not see most of the equations. Please do not e-mail me asking why the equations do not display!