Sol::Tutorials

# Sol's Graphics for Beginners

## 27 - Tuning Gameplay

Feel free to make a copy of the project, or continue from the last one.

Tuning of the gameplay is not something we can do on one go. At this point, however, we'll do a couple of changes to how the game is played. The most major one is the new, hopefully improved, physics model.

Note that the physics model is still a hack, and is not based on any real laws of physics (that I've heard about, anyway).

The motivation is to let the ball roll in a different direction than where the ball is moving on the slippery surface. Thus, we'll de-couple the rolling from the movement, and then add a somewhat less-tight coupling back.

Couple new variables (in main.cpp and with the extern-prefix in gp.h):

// Player's roll vector
float gXRoll;
float gYRoll;

Go to reset() in game.cpp, and set the roll values to zero.

Next, in render(), instead of adjusting gXMov and gYMov when a key is pressed (or joystick moved), adjust the gXRoll and gYRoll variables:

while (gLastTick < tick)
{
int currenttile = (((int)gYPos) / TILESIZE) * gLevelWidth + ((int)gXPos) / TILESIZE;

if (gKeyLeft) gXRoll -= THRUST;
if (gKeyRight) gXRoll += THRUST;
if (gKeyUp) gYRoll -= THRUST;
if (gKeyDown) gYRoll += THRUST;

if (gJoystick)
{
gXRoll += (SDL_JoystickGetAxis(gJoystick, 0) / 32768.0f) * THRUST;
gYRoll += (SDL_JoystickGetAxis(gJoystick, 1) / 32768.0f) * THRUST;
}

Next, find the line where we're adding to the gRoll variable, and change it to use the gXRoll and gYRoll instead of gXMov and gYMov:

gRoll += (float)sqrt(gXRoll * gXRoll + gYRoll * gYRoll);

Finally, find the call to drawball(), and change it to calculate the direction from gXRoll and gYRoll instead of gXMov and gYMov:

// draw the player object
drawball((int)(gXPos + gCameraX), (int)(gYPos + gCameraY),
RADIUS, BALLCOLOR, BALLLOCOLOR, BALLHICOLOR,
gRoll/10.0f, (float)atan2(gYRoll, gXRoll));

If you compile and run, the player's ball will stay in one place, but you can change its roll in various directions.

To re-couple the roll and the motion, we'll need to transfer some of the roll to the motion, but also some of the motion back to the roll. It's more than likely that there's some law of physics that could get this done, but, for now, we'll play by ear.

In render(), find the switch-structure in the physics loop that checks whether we're rolling on smooth, rough, or default. It changes to look like this:

switch (gLevel[currenttile])
{
case LEVEL_SMOOTH:
gXMov = (gXMov * 63 + gXRoll) / 64;
gYMov = (gYMov * 63 + gYRoll) / 64;
gXRoll = (gXMov + gXRoll * 63) / 64;
gYRoll = (gYMov + gYRoll * 63) / 64;
break;
case LEVEL_ROUGH:
gXMov *= SLOWDOWNROUGH;
gYMov *= SLOWDOWNROUGH;
gXMov = (gXMov + gXRoll) / 2;
gYMov = (gYMov + gYRoll) / 2;
gXRoll = (gXMov + gXRoll) / 2;
gYRoll = (gYMov + gYRoll) / 2;
break;
default:
gXMov *= SLOWDOWN;
gYMov *= SLOWDOWN;
gXMov = (gXMov * 7 + gXRoll) / 8;
gYMov = (gYMov * 7 + gYRoll) / 8;
gXRoll = (gXMov + gXRoll * 7) / 8;
gYRoll = (gYMov + gYRoll * 7) / 8;
}

gXPos += gXMov;
gYPos += gYMov;

Compile and run. This takes care of most of the work, but there's still a bunch of glitches to work out.

Since we're playing by ear, the multipliers were put in by testing out several values, and trying out what 'feels' good.

First off, try to collide with a wall. You'll notice that the ball doesn't bounce anymore. This is because while we're causing the motion vector to reverse, the roll stays the same. Find the collision-response code, and give the roll vector the same treatment as what we give to the motion vector:

// Adjust the roll vector based on the collision
gXRoll -= dot * 1.5f * normalx;
gYRoll -= dot * 1.5f * normaly;

Note that we're not recalculating the dot product etc. This could potentially cause some problems, but based on some testing it doesn't feel weird. In most cases, the roll and the motion vector are pointing at the same direction.

A related problem is that the roll is affecting the "slide" tiles. The tile handling code changes to:

case LEVEL_LEFT:
gXMov -= SLIDEPOWER;
gXRoll -= SLIDEPOWER;
break;
case LEVEL_RIGHT:
gXMov += SLIDEPOWER;
gXRoll += SLIDEPOWER;
break;
case LEVEL_UP:
gYMov -= SLIDEPOWER;
gYRoll -= SLIDEPOWER;
break;
case LEVEL_DOWN:
gYMov += SLIDEPOWER;
gYRoll += SLIDEPOWER;
break;

Compile and run. In the second level, you'll find that the arrows are way too powerful now. Go to gp.h, and change the SLIDEPOWER to:

// Sliding tile thrust power
#define SLIDEPOWER 0.04f

It's a bit more than half of what it used to be.

Now the new physics model is implemented. One of the irritating bits about it is that if you have a small gap in the wall, it is very difficult to hit it if you have some speed.

We could adjust the ball's size to make it proportionally smaller than the gap. Instead, let's reduce the 'bouncyness' of the wall collisions. Find the collision response code and change the multipliers to 1.25f:

// Adjust the motion vector based on the collision
gXMov -= dot * 1.25f * normalx;
gYMov -= dot * 1.25f * normaly;

// Adjust the roll vector based on the collision
gXRoll -= dot * 1.25f * normalx;
gYRoll -= dot * 1.25f * normaly;

There. Compile and run.

Currently the game gives the player 10 seconds to solve the level. We'll want to configure this on a per-level basis, since solving some levels will take longer than some others. We'll add a name to the levels while we're at it.

New variables: (main.cpp and gp.h again)

// Level name string
char *gLevelName;
// Level time limit
int gLevelTime;

In main.cpp, find init() and set gLevelName to NULL.

We'll change the level-loading code so that the new data is stored after the map. To achieve this, we'll need to invent a "end of level data" character. We'll use the '@' sign for this. First, we'll need to change the level height-detecting code to halt at this new character. In reset(), the level height-calculating code changes to:

gLevelHeight = 1;
int ch = 0;
while (!feof(f) && ch != '@')
{
ch = fgetc(f);
if (ch == i)
gLevelHeight++;
}
fseek(f, 0, SEEK_SET);

Next we'll need to clear the old level name and set the default time limit. Find the fclose(f) call. Paste the following before it:

delete[] gLevelName;
gLevelName = NULL;
// default time limit
gLevelTime = TIMELIMIT;

We'll use the default if this new information is not found.

Next, let's check if we do have the new information in this file, or if we're using an old version of the map. Paste the following after what you just pasted:

ch = 0;
while (!feof(f) && ch != '@')
ch = fgetc(f);
if (ch == '@')
{
}

int len = (int)strlen(name);
gLevelName = new char[len + 1];
memcpy(gLevelName, name, len + 1);

If we can find a '@'-character, we're dealing with a new version file. Otherwise, we'll use the default values, and the level name will be the same as the filename.

We're not using strdup to clone the filename, as we don't know how strdup is allocating memory, and we're using delete[] to delete the memory. More than likely, strdup is using malloc() instead of the 'new' operator, and mixing the two memory allocation patterns is generally frowned upon (even though it usually seems to work).

Next, we'll need to load the time limit and the level name. We'll delimit these fields with '@' signs. Paste the following inside the 'if' block that we just pasted in:

i = 0;
ch = 0;
while (!feof(f) && ch != '@')
{
ch = fgetc(f);
name[i] = ch;
i++;
}
name[i-1] = 0;
gLevelTime = atoi(name);

Here we're scanning the file for the next '@'-sign, and we're storing the values in the 'name' array, which contained the generated filename. After we hit the '@'-sign, we're overwriting it with a zero, leaving the 'name' array with the contents of the first field, which is the level time limit.

We're using the standard C atoi-function (ascii string to integer) to convert the field into an integer.

Getting the next field works exactly the same way. Paste the following after the above:

i = 0;
ch = 0;
while (!feof(f) && ch != '@')
{
ch = fgetc(f);
name[i] = ch;
i++;
}
name[i-1] = 0;

If we end up adding much more fields, it will probably make sense to refactor this code into a function.

At this point you may wish to add the new data to a couple of levels, or grab these level files which have the data added to them.

Go to the end of the 'render' function, and replace the score drawing code with the following:

// draw status strings
char statusstring[80];
sprintf(statusstring, "'%s', time limit:%ds", gLevelName, gLevelTime);
drawstring(5, 5, statusstring);

sprintf(statusstring, "Score:%d", gScore);
drawstring(5, 22, statusstring);

int secondsleft = gLevelTime - (gLastTick - gLevelStartTick) / 1000;
if (secondsleft < 0)
secondsleft = 0;
sprintf(statusstring, "Time:%d", secondsleft);
drawstring(5, 39, statusstring);

Now we can see the level name and time limit. There's one little change that we'll need to do to finish off the level timings. In render(), we're checking for collision with the LEVEL_END tile, and we're calculating the time bonus. The calculation uses TIMELIMIT instead of gLevelTime.

At this point we'll also simplify the scoring a bit. Change the level end handling to:

case LEVEL_END:
{
int secondsleft = gLevelTime - (gLastTick - gLevelStartTick) / 1000;
if (secondsleft < 0)
secondsleft = 0;
gScore += 100 + secondsleft * 25;
gCurrentLevel++;
reset();
}
break;

We'll need the { .. } code block because we're defining a new local variable, which is not possible inside a 'switch' structure.

After this the collectibles have no effect. Go to collectibecollision() in game.cpp and give the player 10 points for each collectible:

gScore += 10;

That's it for the game tuning for a while. From now on we'll concentrate on all the additional stuff that will make this look like an actual game, and there's surprisingly much left to do.

Next we'll add some more structure to the game's flow with 28 - Game States.

Having problems? Improvement ideas? Just want to discuss this tutorial? Try the forums!

Any comments etc. can be emailed to me.

Site design & Copyright © 2017 Jari Komppa
Possibly modified around: June 01 2010