Torque 2D/GenreTutorials/PlatformerLevels

From TDN

Introduction


The purpose of this tutorial is to create a system that allows you to load and unload levels. In a game with any decent number of assets, you're not going to be able to load everything at the start. You'll need to split up your assets and load them and unload them as needed. Of course, the logical way to do this is level by level. This system doesn't really have anything to do with platformers specifically, aside from the fact that they usually have several levels, so you should be able to use it in any game type. The annoying thing is, the actual sample game is going to play identically at the end of this tutorial as it did at the end of the last. Visually, there will have been nothing accomplished, but you should feel much better about the organization and usability of the code.





The Level Format


The first thing we need to do is create a format with which we can easily load and unload objects from a single file. The SimGroup was designed specifically for this purpose. The normal way to add objects to a SimGroup, and the way we have used up till now, is like this:

new SimGroup(ExampleGroup);
%object = new T2DSceneObject { scenegraph = t2dscene; };
ExampleGroup.add(%object);

But, there exists another way that simplifies this process when you are planning on adding a bunch of objects to a group. The syntax looks like this:

new SimGroup(ExampleGroup)
{
   new T2DSceneObject(ExampleObject)
   {
      scenegraph = t2dscene;
   };
};

This is what we will use. Open the folder in the T2D/data directory called "levels". Inside this folder, create a file called testLevel.cs. I am going to now just give you the contents of the level file that will create a level identical to the one we have been using and then I'll describe the caveats of the format. Open up testLevel.cs and add this to it:

$gravity = 700;
$spawnPoint = "-20 -30";
$enemySpawnPoint0 = "50 0";
$triggerSpawnPoint0 = "20 100";
$triggerSpawnSize0 = "20 5";

new SimGroup(LevelGroup)
{
   new SimGroup(ConfigDatablockGroup)
   {
      new t2dTileMapDatablock(TileMap_Config)
      {
         tileMapFile = "~/data/tilemaps/platformer.tile";
      };
      new t2dImageMapDatablock(Enemy_Config)
      {
         imageMap = enemyImageMap;
         constantForce = 0 SPC $gravity;
         graviticConstantForce = true;
         collisionActiveSend = true;
         collisionActiveReceive = true;
         collisionPhysicsSend = true;
         collisionPhysicsReceive = false;
         collisionCallback = true;
         collisionResponseMode = CLAMP;
         group = $enemyGroup;
         layer = $enemyLayer;
         collisionGroupMask = BIT($platformGroup);
         collisionLayerMask = BIT($platformLayer);
      };
      new t2dSceneObjectDatablock(Trigger_Config)
      {
         group = $triggerGroup;
         layer = $platformLayer;
         collisionGroupMask = BIT($playerGroup);
         collisionReceiveMask = BIT($playerLayer);
         collisionActiveSend = true;
         collisionActiveReceive = false;
      };
   };
   new SimGroup(ObjectGroup)
   {
      new t2dTileMap(TileMap)
      {
         config = TileMap_Config;
         scenegraph = t2dscene;
      };
      new SimGroup(EnemyGroup)
      {
         new t2dStaticSprite()
         {
            config = Enemy_Config;
            scenegraph = t2dscene;
            move = 1;
            aiState = $aiPatrolState;
            runSpeed = 30;
            airSpeed = 16;
            jumpHeight = 20;
            maxRunSurfaceAngle = 35;
            chaseDistance = 100;
            stopChaseDistance = 150;
            patrolMin = -30;
            patrolMax = 50;
         };
      };
      new SimGroup(TriggerGroup)
      {
         new t2dSceneObject()
         {
            config = Trigger_Config;
            scenegraph = t2dscene;
            enterAction = "playerFall();";
            stayAction = "";
            leaveAction = "";
         };
      };
   };
};

We still have two of the same variables. gravity and spawnPoint. By putting them in the level file, we can have different values for different levels. You may not need to change gravity every level, but you probably will want to change the spawn point of the player. The next three variables store the position to spawn the enemy and create and size the trigger.

The rest of this is the LevelGroup. Every object that is specific to a level should be within this group so it can be deleted at the end of the level. From previous tutorials, our level has had three objects in it. A single enemy, a trigger, and the tile map. So, what are the extra three objects in this group? T2D provides two ways to create objects. The first and most common is the way we have been doing it. You create the object and then call a bunch of functions to set its parameters. But, we can't call functions inside this SimGroup definition. The other option is to use config datablocks. Each type of T2D object (StaticSprite, AnimatedSprite, etc) has an associated datablock (StaticSpriteDatablock, AnimatedSpriteDatablock, etc). These datablocks provide a simple way to initialize the properties of a T2D object. Just take a look at the datablocks and you'll see what I mean. The bonus of doing things this way is that we only need to create one config datablock for each object type. If you wanted a second enemy, you wouldn't need to create another datablock, just another object in the ObjectGroup section. Very convenient.

You're probably noticing now, that we never actually placed the objects, we just created a couple of variables to hold their position. Let's do that now. Create a new file called level.cs and exec it from initializeT2D.cs:

   exec("./level.cs");

We are now going to create another ScriptObject whose purpose is pretty transparent. The only real reason for it is that Torque's package system (which will be described later) works much nicer when functions are a part of a namespace. This ScriptObject will create that namespace:

new ScriptObject(Level) {
   running = false;
   filename = "";
   packageName = "";
};

Now we have a namespace called Level that we can add functions to. First, the three variables: "running" is just a flag specifying whether or not a level is currently running. "filename" is the file the current level was loaded from, and "packageName" I will describe later. Here is the function that is a part of our new namespace that will position all of our objects:

function Level::onLevelLoaded()
{
   if (isObject(TileMap))
   {
      for (%i = 0; %i < TileMap.getTileLayerCount(); %i++)
      {
         %layer = TileMap.getTileLayer(%i);
         %layer.setEnabled(true);
         %layer.setCollisionActive(false, true);
         %layer.setGraphGroup($platformGroup);
         %layer.setLayer($platformLayer);
      }
   }
   
   if (isObject(EnemyGroup))
   {
      for (%i = 0; %i < EnemyGroup.getCount(); %i++)
      {
         %enemy = EnemyGroup.getObject(%i);
         %enemy.setCollisionMaxIterations(2);
         %enemy.setCollisionPolyPrimitive(8);
         %enemy.setPosition($enemySpawnPoint[%i]);
      }
   }
   
   if (isObject(TriggerGroup))
   {
      for (%i = 0; %i < TriggerGroup.getCount(); %i++)
      {
         %trigger = TriggerGroup.getObject(%i);
         %trigger.setPosition($triggerSpawnPoint[%i]);
         %trigger.setSize($triggerSpawnSize[%i]);
      }
   }
   
   LevelGroup.add(new SimGroup(TriggerCheckGroup));
}

This function is going to be called every time a level is loaded from our loadLevel function that will be created shortly. The first step is setting up our tile map. Since tile maps can potentially have multiple layers, we are looping through all possible layers when setting properties. The next two sections loop through all of the enemies and all of the triggers respectively. Notice the calls to setPosition and setSize. These are referencing the spawn variables we created at the top of testLevel.cs. If you have two enemies, all you have to do is create another variable called $enemySpawnPoint1 and the second enemy will be positioned accordingly. The last line of the function just creates our TriggerCheckGroup that was described in the last tutorial.

Now I'm going to get into the aforementioned package stuff. A package in TorqueScript is a way to put functions in a heirarchy. What we can do is create two functions with the same name in different packages, and when the function is called the one in the package that is active will be executed. And since they are in a heirarchy, a function in a package can call the function with the same name in the previously activated package. It's kind of confusing and hard to explain, so hopefully the example will clear things up. Basically, each level can now have its own onLevelLoaded function that does its own initializition. Add this to testLevel.cs:

package TestLevel
{
   function Level::onLevelLoaded()
   {
      Parent::onLevelLoaded();
      %layer = TileMap.getTileLayer(0);
      %layer.setPosition("0 0");
      $camera.setLimits(%layer.getArea());
      resetPlayer($spawnPoint);
      
      Level.packageName = TestLevel;
   }
};

activatePackage(TestLevel);

First we create the package. Any function defined within the first opening curly brace and the last closing curly brace of this code block will be a part of this package. The last line, activatePackage(TestLevel), sets the TestLevel package as the current package. So, anytime after this file is executed, when onLevelLoaded is called, this is the function that is executed, not the one we previously defined in level.cs. So then, what was the point of that one? The first line in this function (Parent::onLevelLoaded) is actually calling that function. Essentially what this allows us to do is perform all of the initialization that occurs for every level in one place, the level.cs version of the function, and still have a place to do level dependent intiialization.

The last line of the function that sets the packageName variable, must always be set to the name of the level's package. We need it so we can deactivate the package when the level is ended.






Loading and Unloading Levels


The final part of this tutorial is the actual loading and unloading of the levels. For this we are going to create two new functions in level.cs. loadLevel and endLevel. loadLevel will take as a parameter the filename of the level you want to load. Here it is:

function loadLevel(%level)
{
   if (Level.running)
      endLevel();
   
   if (isFile(%level))
   {
      echo("Loading Level" @ %level);
      exec(%level);
      Level.running = true;
      Level.filename = %level;
      
      if (isObject(LevelGroup))
         Level.onLevelLoaded();
      else
         warn("Invalid level file: LevelGroup not found");
   }
   else
   {
      warn("Invalid level file: " @ %level @ " not found");
   }
}

First, if a level is already running, we end it. Then, we make sure the specified file actually exists. If it does, the file is loaded and we set the running status to true. Finally, we make sure the level file contained a LevelGroup and if so, call the onLevelLoaded function. Remember, this is going to call the onLevelLoaded function defined in the level file, which will then call the generic onLevelLoaded function in level.cs.

And now the endLevel function:

function endLevel()
{
   if (isObject(LevelGroup))
      LevelGroup.delete();

   deactivatePackage(Level.packageName);
   Level.running = false;
}

This is pretty simple. If we have a LevelGroup, delete it. Then, deactivate the level's package.

You'll notice we are keeping track of whether or not a level is currently loaded with the Level.running variable. We need this to manage scene updates. If no level is loaded, there is no sense trying to update it. Add this to the beginning of the onUpdateScene function in game.cs:

   if (!Level.running)
      return;

The last thing we need to do is delete all of the creation functions and groups. Those would be createLevel in game.cs, the EnemyGroup and createEnemy in enemy.cs, and the TriggerCheckGroup and createTrigger in trigger.cs. All of this stuff is being handled by our level system now, so these functions are useless.

To use this level system, all you have to do is call loadLevel("levelFile.cs"); anytime you want to load a level. So, you probably want to change the call to createLevel within setupT2DScene() in game.cs to:

   loadLevel("~/data/levels/testLevel.cs");

And, you can create a trigger at the end of a level with enterAction:

loadLevel("~/data/levels/level2.cs");

To load the next level. All you would need is a level2.cs file in the same format as testLevel.cs. If you make use of this and create a bunch of levels, the only thing keeping you from having a complete game is the lack of a menu system.