Home > Game Development Walkthroughs > 2D Platform Games Part 10: Improved Level Management and Storage

2D Platform Games Part 10: Improved Level Management and Storage

February 6, 2013 Leave a comment Go to comments

IMPORTANT! To run the pre-compiled EXEs in this article, you must have Windows 7 Service Pack 1 with Platform Update for Windows 7 installed, or Windows 8.

This article builds upon the demo project created in 2D Platform Games Part 9: Storing Levels in Files / Level Editors. Start with 2D Platform Games Part 1: Collision Detection for Dummies if you just stumbled upon this page at random!

Download source code and compiled EXE for the code in this article as well as the complete source code and compiled EXE for the level editor.

In Part 9 we looked at how to store and retrieve game levels from files in a format that could be used both by the game itself and by an auxiliary level editor application. In this article we will make things a little more robust and usable with some improvements:

  1. Allow the game to load the specified level as a command-line argument
  2. Re-factor all of the level data into a single class so it can be version-controlled and remove duplicate variable declarations in the game and level editor
  3. Add functions to the Platform class to create platforms from the GeometryDefinitions stored in the level files, neatening things up by making them more object-oriented and removing duplicate code from the game and level editor

Let’s get cracking!

Command-line arguments in Windows applications

In console C++ applications we can just use argc and argv from the parameter list in main() to inspect each command-line argument. Unfortunately the entry point for Windows applications – WinMain() – doesn’t provide us with this data. PJ over at My Coding Misadventures (a nice Win32 blog I might add) has written a great article on Retrieving comamnd line parameters from WinMain in win32 if you want the full details, suffice to say that if you are using Visual Studio you can cheat and use the compiler-defined variables __argc and __argv (or __wargv for wide-character arguments) to do the job as follows:

Game class definition:

// Default level name to load
std::string levelFilename;

Game class constructor:

// Level to load (may be from command line)
levelFilename = "default.splevel";

if (__argc > 1)
	levelFilename = StringFactory(__argv[1]);

In SetupResources():

LoadLevel(levelFilename);   // instead of LoadLevel("default.splevel");

StringFactory() is a helper template function in Simple2D defined as:

// Convert numbers and chars to strings easily
template <typename String>
string StringFactory(String s)
{
	stringstream ss;
	ss << s;
	return ss.str();
}

(include sstream)

Piece of cake!

Moving all level data into a single class object

Our main motivation for doing this is that Boost.Serialization (discussed in Part 9) provides versioning for classes, which is very useful during the development of your game as the level file format is volatile while features are added and changed constantly. As a side benefit, it saves duplication on defining variables in the game and level editor such as the player’s start position and background image filenames and keeps everything tidy and in one place. Additionally, when we need to add new items to the level definition, we can just add them to the class definition and they will be automatically serialized and de-serialized in level files and automatically available to both the game and the level editor.

In our project so far, we require the following data to fully define a level (with more to be added in later parts):

  • The player’s starting X and Y position in the game world
  • Definitions for each platform (this includes their position, shape and any movement parameters – we have already defined the GeometryDefinition class for this)
  • The filenames for the background and parallax background

We can roll this into a class pretty easily. We place it in SharedPlatformCode.h so it is available to both the game and level editor:

// Definition of an entire level
class LevelDefinition
{
public:
	// Player start position in level
	float playerStartX, playerStartY;

	// Geometry definitions for each platform
	vector<GeometryDefinition> worldGeometry;

	// Background and mediumground filenames
	WCHAR backgroundFile[1000];
	WCHAR mediumgroundFile[1000];

	// Load/save a level from/to file
	static LevelDefinition Load(std::string filename);
	void Save(std::string filename);

private:
	// Allow Boost::serialization access to private members
	friend class boost::serialization::access;

	// This template describes how to serialize and deserialize the level into/from a file
	template <typename Archive>
	void serialize(Archive &ar, const unsigned int version)
	{
		ar & playerStartX; ar & playerStartY;

		ar & worldGeometry;

		ar & backgroundFile; ar & mediumgroundFile;
	}
};

// Version 0: initial
BOOST_CLASS_VERSION(LevelDefinition, 0)

// Prevents warnings from Boost when serializing enums
BOOST_CLASS_TRACKING(LevelDefinition, boost::serialization::track_never)

As with our previous serializable object GeometryDefinition, when you make a change to LevelDefinition, you will increment the version number in the BOOST_CLASS_VERSION macro and update the serialize() method appropriately. Note that we have a vector array of GeometryDefinitions as part of our level definition class, but if the format of GeometryDefinition changes, you do not need to increment LevelDefinition‘s version number, only that of GeometryDefinition (ie. the class whose definition actually changes).

To be able to serialize a vector, make sure to include boost/serialization/vector.hpp. Notice that one advantage of serializing a vector in one operation rather than iterating over it and serializing each item one at a time is that you no longer need to manually store the size of the vector (number of items in it). Boost.Serialization takes care of it for you.

Loading and saving the level is now accomplished via two methods in the class itself (in SharedPlatformCode.cpp):

// Level definition loader
LevelDefinition LevelDefinition::Load(std::string filename)
{
	std::ifstream LevelFile(filename);
	boost::archive::text_iarchive ia(LevelFile);

	LevelDefinition level;
	ia >> level;

	LevelFile.close();

	return level;
}

// Level definition saver
void LevelDefinition::Save(std::string filename)
{
	std::ofstream LevelFile(filename);
	boost::archive::text_oarchive oa(LevelFile);

	oa << *this;

	LevelFile.close();
}

A couple of things to note: the loading function is a static member of the class, meaning that you don’t need to instantiate a LevelDefinition object before calling it. You simply call LevelDefinition::Load() and it returns a newly loaded and initialized LevelDefinition instance for you. On the other hand, the saving function must be called on an instance, and serializes a copy of itself to the specified file.

We now go through the game and level editor code and remove the definitions and any initialization of playerStartX, playerStartY, backgroundFilename, mediumgroundFilename and worldGeometry from the game and level editor’s main class definitions and methods (see the source code for this), and in the editor only, replace them with:

// The level we are editing
LevelDefinition level;

(the game does not need the level definition once the level has been generated, whereas the editor must be able to modify it on the fly)

Next we have to re-factor the load and save functions. Previously loading a level looked something like this:

void MyProgram::LoadLevel(std::string filename)
{
	std::ifstream LevelFile(filename);
	boost::archive::text_iarchive ia(LevelFile);

	// Player start position in level
	ia >> playerStartX;
	ia >> playerStartY;

	// Number of geometry objects
	int numObjects;
	ia >> numObjects;

	// Geometry definitions for each platform
	worldObjects.clear();
	GeometryDefinition d;

	for (int i = 0; i < numObjects; i++)
	{
		ia >> d;

		Platform p;

        /* DIFFERENT PLATFORM INITIALIZATION CODE GOES HERE FOR THE GAME AND LEVEL EDITOR */

		worldObjects.push_back(p);
	}

	ia >> backgroundFilename;
	ia >> mediumgroundFilename;

	LevelFile.close();

    /* IN THE GAME, CODE TO CALCULATE THE WORLD BOUNDING BOX, LEVEL EXTENTS ETC. GOES HERE */
}

The game and level editor both use their own versions of the Platform class, so we can move the per-platform initialization code which is unique to each application into specialized Platform constructors. Loading a level then becomes reduced to:

The level editor:

void MyProgram::Load(std::string filename)
{
	level = LevelDefinition::Load(filename);

	worldGeometry.clear();
	for (auto it = level.worldGeometry.begin(); it != level.worldGeometry.end(); it++)
		worldGeometry.push_back(Platform(*it));
}

The game:

void MyProgram::LoadLevel(std::string filename)
{
	// Load the definition
	LevelDefinition level = LevelDefinition::Load(filename);

	// Set player start position
	playerStartX = level.playerStartX;
	playerStartY = level.playerStartY;

	// Configure platforms
	worldObjects.clear();
	for (auto it = level.worldGeometry.begin(); it != level.worldGeometry.end(); it++)
		worldObjects.push_back(Platform(*it));

	// Configure backgrounds
	background = MakeImage(level.backgroundFile);
	mediumground = MakeImage(level.mediumgroundFile);

    /* BOUNDING BOX, LEVEL EXTENTS CODE ETC. STAYS HERE UNCHANGED */
}

In the level editor, saving the level is changed from:

void MyProgram::Save(std::string filename)
{
	std::ofstream LevelFile(filename, std::ios::out);
	boost::archive::text_oarchive oa(LevelFile);

	// Player start position in level
	oa << playerStartX;
	oa << playerStartY;

	// Number of geometry items
	int s = worldObjects.size();
	oa << s;

	// Geometry definitions for each platform
	for (auto it = worldObjects.begin(); it != worldObjects.end(); it++)
		oa << (*it).def;

	// Name of background and mediumground
	oa << backgroundFilename;
	oa << mediumgroundFilename;

	LevelFile.close();
}

To:

void MyProgram::Save(std::string filename)
{
	// Update all the geometry definitions
	level.worldGeometry.clear();

	for (auto it = worldGeometry.begin(); it != worldGeometry.end(); it++)
		level.worldGeometry.push_back(it->def);

	// Perform the save
	level.Save(filename);
}

The editor does not modify the geometry definitions in the level definition directly, so we first replace the level’s geometry with that being used by the level editor, then save it.

The level editor also needs a function to create a blank level when the application starts or the user chooses to create a new level from the toolbar:

// Create a blank level
void MyProgram::CreateDefaultLevel()
{
	// Set default background filenames
	wcscpy_s(level.backgroundFile, L"bg.png");
	wcscpy_s(level.mediumgroundFile, L"mg.png");

	background = MakeImage(level.backgroundFile);

	// No geometry
	level.worldGeometry.clear();
	worldGeometry.clear();

	// Set default player position
	level.playerStartX = 0;
	level.playerStartY = 0;
}

We could make this a method of LevelDefinition, but the code is only needed by the level editor so we choose to just use a function instead.

This just leaves the issue of creating the Platform constructors which initialize the object using the information from a GeometryDefinition.

Object-oriented platform creation from a definition object

In the level editor this is a cinch since Platform only contains the Direct2D geometry of the platform, and the definition itself:

// Create a platform from a geometry definition
Platform::Platform(GeometryDefinition &d)
{
	geom = MyProgram::Tools.CreateGeometry(d);
	def = d;
}

In the game, we have to do a bit more work as we need the initial bounding box of the geometry and to configure the X and Y pathing animations if the platform is moving. We also have to set the surface type and initial position of the platform in the constructor’s initializer list:

// Create a platform from a geometry definition
Platform::Platform(GeometryDefinition &def)
	: surface(def.surface), defaultX(def.topLeftX), defaultY(def.topLeftY), moving(def.moving)
{
	geom = MyProgram::Tools.CreateGeometry(def);
	bounds = geom.GetBounds();

	if (def.moving)
	{
		boost::function<double (double)> mfX, mfY;

		if (def.moveFuncX == MT_Linear) mfX = Animations::Linear;
		if (def.moveFuncX == MT_Cosine) mfX = Animations::Cos;
		if (def.moveFuncX == MT_Sine)   mfX = Animations::Sin;

		if (def.moveFuncY == MT_Linear) mfY = Animations::Linear;
		if (def.moveFuncY == MT_Cosine) mfY = Animations::Cos;
		if (def.moveFuncY == MT_Sine)   mfY = Animations::Sin;

		x = Animation(mfX, def.moveIntervalX, def.moveAmplitudeX, def.moveBaseX, def.moveCropLowerX, def.moveCropUpperX, def.moveCycleTypeX, false);
		y = Animation(mfY, def.moveIntervalY, def.moveAmplitudeY, def.moveBaseY, def.moveCropLowerY, def.moveCropUpperY, def.moveCycleTypeY, false);
	}
}

And that’s it. We now have a fully encapsulated, serializable and easily extensible level definition class!

Storing as byte data

Note: to reduce file sizes and make it slightly harder for people to abuse your level files, I recommend using binary archives rather than text archives in Boost.Serialization to save and load the levels. The former writes everything as raw data, whereas the latter (which we have been using so far) converts all numbers to a human-readable text format. This change is made in the source code from Part 12 onwards, but in brief, you can just change:

#include <boost/archive/text_oarchive.hpp>
#include <boost/archive/text_iarchive.hpp>

to:

#include <boost/archive/binary_oarchive.hpp>
#include <boost/archive/binary_iarchive.hpp>

and be sure to include the std::ios::binary flag when you open level files for input or output:

// Loading
std::ifstream LevelFile(filename, std::ios::binary);
boost::archive::binary_iarchive ia(LevelFile);

...
// Saving
std::ofstream LevelFile(filename, std::ios::binary);
boost::archive::binary_oarchive oa(LevelFile);

...

Updates to the Level Editor

This part includes a new version of the level editor with the following changes and improvements:

  • The save dialog now automatically defaults to the name of the current file
  • Opening a file will give a warning if there unsaved changes to the current file
  • The file being edited is no longer marked as changed when a mouse drag ends unless there is a selected object
  • The LevelDefinition file format is now used

You Have Now Leveled Up

In Part 11 we are going to look at some tricky collision detection problems that can arise in our game so far, and how to solve them. Until next time!

Share your thoughts! Note: to post source code, enclose it in [code lang=...] [/code] tags. Valid values for 'lang' are cpp, csharp, xml, javascript, php etc. To post compiler errors or other text that is best read monospaced, use 'text' as the value for lang.

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: