TGB/AdvancedPlatformer

From TDN

A More Advanced Platformer

This page is a Work In Progress.

Contents

Preface

This tutorial aims to help you understand how to implement a more polished platformer than the Mini Platformer Tutorial. If you haven't read it, you should do so first.

The platformer we construct here will have smoother play mechanics and more features typical of a "real" game (including support for multiple levels, death, and much more visual bling).

Additionally, this tutorial aims to help you understand how to extend the platformer to meet your particular needs in a way that is clean and maintainable.

Getting Started

We assume you have read the Mini Platformer Tutorial and understand the basics of setting up a project, such as adding imageMaps and animations, creating tilemaps, and creating scenes/levels.

First, please start by creating a new project called "AdvancedPlatformer" and importing the artwork bundle linked to here. Add the resource to your project. Then, create a level as in the Mini Platformer Tutorial, but don't put the player in yet.

We will re-use a piece of code called Image:Actionmap.zip without exploring it in great detail. This piece of code implements a simple game design pattern that allows a player to move left or right without getting confused about when each button was pressed and released. Download it, unzip it, and place actionmap.cs in AdvancedPlatformer/gameScripts/ and add this in game.cs after "Canvas.setCursor(DefaultCursor);"

    exec("~/gameScripts/actionmap.cs");

Now, we're ready to begin.

Creating a Player

The first step is to create a datablock for our player in AdvancedPlatformer/main.cs. Add this code to the end of that file:

//----------------------------------------------------------------------------
// Configuration Datablocks.  Use these from Level Builder.
//----------------------------------------------------------------------------
if(!isObject(PlayerTemplate)) new t2dSceneObjectDatablock(PlayerTemplate)
{
  // The player.  Note that mountCamera is mutually exclusive with followX/Y,
  // and cameraRangeX/Y are not obeyed by mountCamera.
  class="PlayerClass";
  CollisionActiveSend = "1";
  CollisionActiveReceive = "1";
  CollisionPhysicsSend = "1";
  CollisionPhysicsReceive = "1";
  CollisionPolyList = "-0.372 -0.627 0.401 -0.627 0.401 0.712 -0.372 0.712";
  ConstantForce = "0.000 400.000";
  ConstantForceGravitic = "1";  
};

A datablock is, in essence a way to set a bunch of values on an object all at once. It's actually a little more complex than that but a more thorough discussion is beyond the scope of this article. You will need to restart TGB for this change to take effect.

So let's see what we've done. We've defined a "template" for the player that sets it up to send and receive collisions, defines a collision polygon for the player, and a constant gravity to act upon the player. We've also said that the player class will be "PlayerClass" although you'll note that we have done nothing to actually create this "class" yet. That's ok, because we don't have to explicitly define it.

A "class" from the standpoint of TorqueScript is essentially a way to bind code that you write to objects that are created in the Level Builder (or via script). This is an elegant way to separate the code from the art.

Now, create a t2dAnimatedSprite using animation XXXXX and drop it where you want the player to start out in the level. Edit its properties, and go to "Scripting". Choose "PlayerTemplate" from the drop down next to "Config Datablock". Un-select the player object, then select it again and you'll see that all our properties are set as per the datablock.

I want to take a second to discuss the "Persist" checkbox, and why we're not using it. Checking that will cause an object to be loaded with every level, exactly as defined in Level Builder. At first glance, this seems like a great way to avoid some tedious repetition but don't do it! You may find that you want the player to start in a different position on another level. Or perhaps the player will be subjected to a different degree of gravity. Or perhaps you'll have a level that doesn't even have a player object (think "cut scene"). If you make it persistent, it will be in every level and it will be exactly the same in every level.

Now that we've made a player object and we've set it up to have the attributes we want, the time has come to attach some behavior to the player. We're going to put all the code related to the player in a file called AdvancedPlatformer/gameScripts/player.cs. You'll want to exec this script from game.cs, after the line you added to exec actionmap.cs.

To start with, lets set up some constants. If you're going to need to use a value in more than one place, or you might need to change the value later, you should make a constant for it. Put this at the top of player.cs:

// Constants.  Tweak these, and the constant force on the player object, to
// adjust how quickly the player moves.
$xSpeed = 60;
$ySpeed = -200;

The first step is to bind some keyboard controls when (and ONLY when) there's a player object. We do this by creating a piece of code that executes when the level is loaded. Note that this code only executes in levels where there's an object with the class attribute set to "PlayerClass".

Add this to player.cs:

function PlayerClass::onLevelLoaded(%this, %scenegraph)
{
    $player = %this;

    moveMap.bindExclusiveCommand(keyboard, "space", %this, "%obj.playerJump();", "", "jumpDirection", "up");
    moveMap.bindExclusiveCommand(keyboard, "left", %this, "%obj.playerLeft();", "%obj.playerLeftStop();", "leftRightDirection", "left");
    moveMap.bindExclusiveCommand(keyboard, "right", %this, "%obj.playerRight();", "%obj.playerRightStop();", "leftRightDirection", "right");
     
    moveMap.push();  
}

This is where we're using actionmap.cs. What we've done is we've created a command "group" called jumpDirection and put one command in it -- the jump command. Then we've created a second such group named leftRightDirection and put two commands in it -- one to move the player left, one to move the player right. What actionmap.cs is doing here is making sure that code gets called in the right order. Specifically if you press and hold left, then press and hold right, then release left, it's easy to have things behave badly (as in the Mini Platformer Tutorial). If you press those key combinations here, the code that's called will be:

  1. playerLeft()
  2. playerLeftStop()
  3. playerRight()
Not:

  1. playerLeft()
  2. playerRight()
  3. playerLeftStop()

One other thing to think about here is that when the player object goes away, the keyboard controls will STILL be bound, which we probably don't want. So let's add some code to unbind things when the player goes away.

function PlayerClass::onRemove(%this)
{
    moveMap.pop();
    $player = null;
}

So now let's see about actually implementing those methods:

function PlayerClass::playerLeft(%this)
{
  %this.setLinearVelocityX(-$xSpeed);
}

function PlayerClass::playerLeftStop(%this)
{
  %this.setLinearVelocityX(0);
}

function PlayerClass::playerRight(%this)
{
  %this.setLinearVelocityX($xSpeed);
}

function PlayerClass::playerRightStop(%this)
{
  %this.setLinearVelocityX(0);
}

function PlayerClass::playerJump(%this)
{
  if(%this.getLinearVelocityY() == 0)
    %this.setLinearVelocityY($ySpeed);
}

We're using the physics system to propel the player left, right, or up as needed. We rely on gravity (remember the constant force we set in PlayerTemplate) to bring them back down. The if statement in playerJump prevents us from being able to double-jump, or jump while falling. It makes sure we're on solid ground before we can actually jump.

Save player.cs, and game.cs, and hit play. You should find yourself able to jump around, and run back and forth. But you will also encounter a couple nasty quirks: If you hit the ceiling, you will sometimes become "stuck". Sometimes, you won't actually jump (as if you're "stuck" to the floor). You may even get stuck to the walls every now and then. And if you are trying to get onto a platform you may find yourself frustrated by the fact that if you hit the platform on the way up you don't resume moving left/right after you've cleared it.

What has happened here is that we've encountered some quirks in the physics system. This is a natural and unavoidable side effect of using a very complicated tool (the physics system) to accomplish something simple (basic movement). Some are intentional (not resuming lateral motion after clearing a platform that you hit on the way up). Some are not (getting stuck to the floor/ceiling/walls). We need to either work around these problems or find an alternative way to do what we want. The alternative is pretty complicated so let's just work around our physics issues.

Let's start with our stickiness problem. If we're sticking to objects unexpectedly because we're next to them, then an easy solution is to not be next to them. We're going to accomplish that by moving our player object explicitly (instead of using physics) just a little bit before setting the physics in motion. Replace the playerLeft, playerRight, and playerJump methods with this code:

function PlayerClass::playerLeft(%this)
{
  %this.setPositionX(%this.getPositionX() - 0.5);
  %this.setLinearVelocityX(-$xSpeed);
}

function PlayerClass::playerRight(%this)
{
  %this.setPositionX(%this.getPositionX() + 0.5);
  %this.setLinearVelocityX($xSpeed);
}

function PlayerClass::playerJump(%this)
{
  if(%this.getLinearVelocityY() == 0) 
  {
    %this.setPositionY(%this.getPositionY() - 0.5);
    %this.setLinearVelocityY($ySpeed);
  }
}

Hit play, and try it out. Your player should be moving and jumping much more reliably now. However, there's still the matter of resuming left/right movement during a jump when an obstacle has been cleared. And we also have a tendency to stick to the ceiling. For the former, what we essentially want to do is reapply the physics to the player object when we're no longer colliding with a platform. And only if we're supposed to be moving left/right. Working around these two issues is a bit on the tricky side:

function PlayerClass::updateHorizontal(%this)
{
  if ((%this.leftRightDirection !$= "") && (mAbs(%this.getLinearVelocityX()) != $xSpeed) && %this.getLinearVelocityY() != 0)
  {
    // X velocity is positive if we're moving right, negative otherwise.
    // The logic of testing and correcting the velocity is the same, except
    // for the sign of the numbers involved.
    if (%this.leftRightDirection $= "left")
        %facing = -1;
    else
        %facing = 1;

    // See if whatever caused us to stop moving is out of the way by basically
    // setting the object's velocity in the direction we OUGHT to be going,
    // and asking the engine if we're going to run into something a tick from
    // now (give or take).  We set the velocity back to whatever it was 
    // immediately so the player doesn't ACTUALLY move.
    %tmp = %this.getLinearVelocityX();
    %this.setLinearVelocityX($xSpeed * %facing);
    %collision = %this.castCollision(0.005);
    %this.setLinearVelocityX(%tmp);

    if(%collision !$= "")
    {
      // We ARE colliding with something.  Is it in front of the direction 
      // we're trying to move in though?  To check that, we get the fifth
      // field from the collision which will be 1 if the collision is on the
      // right, -1 if it's on the left, or 0 if the collision is above/below.
      %x = getWord(%collision, 4);
      // If we're colliding, but not to the left/right, then let's go.
      if (%x != 0)
        %this.setLinearVelocityX($xSpeed * %facing);
    }
    else
    {
      // We're not colliding at ALL, so let's go!
      %this.setLinearVelocityX($xSpeed * %facing);      
    }
  } 
}

function PlayerClass::updateVertical(%this)
{
  %collision = %this.castCollision(0.005);
  if(%collision !$= "")
  {
    // We're colliding.  Are we colliding with something ABOVE us?
    if (getWord(%collision, 5) >= 1) 
    {
      // Yep.  Let's pre-emptively unglue us from the ceiling by making sure
      // we're not still moving up, AND moving us away from the ceiling a bit.
      %this.setLinearVelocityY(0);
      %this.setPositionY(%this.getPositionY() + 0.1);
    }
  }
}

function PlayerClass::updateMovement(%this)
{
  %this.updateHorizontal();
  %this.updateVertical();
}

function t2dSceneGraph::onUpdateScene()
{
  if (isObject($player) && $player.getEnabled())
    $player.updateMovement();
}

There's a lot going on here, so let's go over it piece-by-piece. First off, we've created a method called t2dSceneGraph::onUpdateScene. What we've done is added a bit of code to the scene graph itself, to be executed whenever it updates the scene (to repaint, or update physics, or whatever). Because this happens very often you want to do as little work as possible from there! You will see very bad performance if you try to do too much here.

In our case, we are determining if there is a player object, and if so we are updating both the horizontal and vertical movement.

The updateVertical method basically asks the engine to look forward in time just a tiny bit and see if we're GOING to collide with something ABOVE us in the future. If so, we behave as if we've already done so: We stop moving up, and we move just a smidge away from the ceiling just in case we're already very close to it.

The updateHorizontal method is more complicated. First off, we only care if we've stopped moving left/right AND we are currently moving up/down. Then we want to see if we're going to be colliding with something in the direction we want to be moving in the near future. If not, the way is clear for us and we should resume our left/right motion if the player is still pressing the left/right key.

Save, hit play, and try it out. You should now have a fairly robust and comfortable set of game mechanics that will feel quite solid and not at all frustrating.

Camera Scrolling

We're going to look at two different ways of managing the camera. The first and simplest method is to simply mount the camera onto the player. So the player will always appear at the center of the screen. This works, but it has limitations, such as needing to have a large border around your levels (or getting a black border by default) when the player is near the edge. The second method will allow us to have the camera follow the player but always stay within a certain range of values. You may recognize this style of scrolling from Super Mario Brothers, or Legend of Zelda on Super Nintendo.

First, extend your level so that the full area is twice as wide as the screen and twice as tall, with platforms allowing the player to reach any portion of it and a border around the whole thing.

Then, add a new constant to the top of player.cs:

$mountForce = 20;

This value will control the "lag" between the player's movement and the camera's movement.

Now, let's change the onLevelLoaded method by adding these line of code (it doesn't matter where, but I put them after the "$player = %this;" line myself):

    if (%this.mountCamera)
      sceneWindow2D.mount(%this, "0 0", $mountForce, true);  
}

And in main.cs, add a new value to our PlayerTemplate datablock:

  mountCamera="0";

Save, restart TGB, hit play, and... Nothing. No difference. Exactly as it ought to be. In order to enable this camera behavior, select your player object and go down to "Dynamic Fields". Note that there's a field for "mountCamera" there. Change the value to 1, save, and hit play. Now the camera follows the player. It's simple, but it works. Now let's try something a bit more polished and refined.

We start by making another change to main.cs, to add four new attributes to PlayerTemplate:

  followX="0";
  followY="0";
  cameraRangeX="0 0";
  cameraRangeY="0 0";

The first two are flags that tell whether or not we want the camera to move along the X and Y axis (respectively). In Super Mario Brothers, the camera only moved along the X axis for example. The second two will tell us, for each axis, what are the minimum and maximum values the camera should stay between.

Let's start with the Y axis. Insert this at the very top of the updateVertical method:

  if (%this.followY)
  {
    %cameraPosition = sceneWindow2d.getCurrentCameraPosition();
    %playerY = getWord(%this.getPosition(), 1);
    
    if (%this.cameraRangeY !$= "")
    {
      %minY = getWord(%this.cameraRangeY, 0);
      %maxY = getWord(%this.cameraRangeY, 1);
      
      if (%playerY < %minY)
        %playerY = %minY;
      else if (%playerY > %maxY)
        %playerY = %maxY;
    }
    
    if (%playerY != getWord(%cameraPosition, 1)) 
      sceneWindow2d.setCurrentCameraPosition(getWord(%cameraPosition, 0) SPC %playerY);
  }

Now, on your player object, set mountCamera to 0, and followY to 1. Calculating the cameraRangeY value is a little bit tricky. First, find the size and position of a tilemap or image object that covers the whole of the visible level. You will also need to know the camera size, which is 200x150 by default. To find out what it is, click on the grid in the background in level builder, go to the Edit tab, and look at the Camera rolldown. The Width and Height attributes are what you want here.

Start by finding the minimum and maximum Y values that exist in your level. This basically means take the Y coordinate of the position of the object, and add/subtract half of the height of the object. Subtracting half the height from the Y position gives you the minimum Y value that exists in your level, and adding it gives you the maximum. This is because the position coordinates of the object represent the center of the object. Now, if you just use these values you'll find you still get that nasty border. That's because you've basically said that the center of the camera can go to the very edge of your world. What you want to do is add half the height of your camera to the minimum, and subtract half the height from the maximum.

So if my camera is 200x150 by default, and my level is represented by a 400x300 tilemap (size, not tile count) at position 0,0 then the extent of my world is -150 to 150. Talking half the height of the camera off of that gives me -75 to 75. Put "-75 75" in cameraRangeY and you're set. Notice that the camera will follow the player up or down until it hits one of these limits at which point the player will continue moving getting closer to the edge of the screen.

But we're not done yet. We still need to implement the same thing for the X axis. Add this to the very top of updateHorizontal:

  if (%this.followX)
  {
    %cameraPosition = sceneWindow2d.getCurrentCameraPosition();
    %playerX = getWord(%this.getPosition(), 0);
    if (%playerX != getWord(%cameraPosition, 0)) 
    {
      if (%this.cameraRangeX !$= "")
      {
        %minX = getWord(%this.cameraRangeX, 0);
        %maxX = getWord(%this.cameraRangeX, 1);
        
        if (%playerX < %minX)
          %playerX = %minX;
        else if (%playerX > %maxX)
          %playerX = %maxX;
      }
      sceneWindow2d.setCurrentCameraPosition(%playerX SPC getWord(%cameraPosition, 1));
    }
  }

Now set followX. If you're using the same world size that I am, your cameraRangeX is "-100 100". Calculating it works the same but use width instead of height and X coordinates instead of Y coordinates.

Switching Levels

Changing levels is usually about achieving an objective. There can be any number of possible objectives, including:

  • Reach a particular location / touch a particular object.
  • Collect a certain number of items.
  • Survive for a certain length of time.
  • Defeat a particular opponent.

Right now, only two of those objectives are meaningful: Touch an object, and survive for a certain length of time. We will implement both, starting with touching an object.

Our target object, like our player object, needs some code associated with it and will have special characteristics. So we're going to define another template datablock in AdvancedPlatformer/main.cs:

if(!isObject(LevelEndTargetTemplate)) new t2dSceneObjectDatablock(LevelEndTargetTemplate)
{
  // An instances of this class presents a target which the player must make
  // physical contact with in order to switch to the next level.
  class="LevelEndTargetClass";
  nextLevel="baseLevel.t2d";
  CollisionActiveSend = "1";
  CollisionActiveReceive = "1";
  CollisionCallback = "1";
  CollisionPhysicsSend = "0";
  CollisionPhysicsReceive = "0";
};

Again, we're inventing a new class to connect our code with the object. And again, we're creating a new Dynamic Field. This field (nextLevel) will specify the filename to switch to when the object is touched. Remember, you need to create a player object in the second level as well.

Create a new level and save it. Now, in your first level create a t2dStaticSprite somewhere. For the Config Datablock, choose LevelEndTargetTemplate. Change the nextLevel field to match the filename (without path) of your second level. Save your level. Create a new file named AdvancedPlatformer/gameScripts/level.cs containing the following:

$basePath = "game/data/levels/";

function LevelEndTargetClass::onCollision(%this, %dest)
{
  if (%dest.class $= "PlayerClass")
  {
    %level = $basePath @ %this.nextLevel;
    if (isFile( %level ) || isFile( %level @ ".dso"))
      sceneWindow2D.schedule(10, loadLevel, %level);
  }
}

The first thing to note about this method is that it only triggers the level change if the player touches it. The next thing is that we are calling schedule on the scene window, rather than just calling loadLevel directly. The reason for this is that if you call loadLevel directly, TGB will crash. Why is this? Well, think about it for a moment: When you load a new level, it needs to get rid of the objects from the current level. Unfortunately, because you are running code that is a part of one of these objects, the engine can't get rid of it without screwing up its internal state. Therefore, we need to NOT be in the LevelEndTargetClass::onCollision method when the level change happens. Scheduling it to happen a few milliseconds later and then immediately terminating execution does a good job of ensuring this. How long is enough? Well, it's always best to err on the side of caution when it comes to timing issues and since our method does absolutely nothing else after the schedule call, 10 milliseconds is a HUGE amount of time. As long as you don't do anything time consuming after you've called schedule you should be fine.

Naturally, you need to exec this file. Add this in game.cs to the same area where we've been putting other exec statements:

   exec("~/gameScripts/level.cs");

Save everything, hit play, and run and jump your way to the target. Bump into it and watch as you jump to the next level. Be aware that you can place as many level end target objects in your level as you wish. You can have each one go to a different target level so your level structure doesn't have to be linear. In fact, you can create a "loop" of levels quite easily if you want.

The next method of changing levels we're going to look at is using a timeout. Timeouts have a couple important uses: They can be used as an objective in a playable level, or they can be used for animated cut scenes. We want to flexible enough with our code that we can make cut scenes without having to "special case" anything.

So let's create a new datablock in main.cs:

if(!isObject(LevelEndTimerTemplate)) new t2dSceneObjectDatablock(LevelEndTimerTemplate)
{
  class="LevelEndTimerClass";
  sceneDuration="10000";
  nextLevel="baseLevel.t2d";
};

We have two custom attributes here. One is the duration, in milliseconds. We're setting the default to be about 10 seconds. Now, in level.cs add the following code:

function LevelEndTimerClass::onLevelLoaded(%this, %scenegraph)
{
  %level = $basePath @ %this.nextLevel;
  if (isFile( %level ) || isFile( %level @ ".dso"))
    sceneWindow2D.schedule(%this.sceneDuration, loadLevel, %level);
}

Restart TGB, and load your second level. Create a t2dSceneObject and assign it a Config Datablock of LevelEndTimerClass. For nextLevel, put the name of your first level. Now, hit play and wait a few seconds. Watch as you switch to your first level with no action of your own.

Dying

A game isn't much of game if there isn't a way to lose. So, we need a way to kill the player off. We're going to break this task into two parts: The first is to make it so the player can die (and respawn). The second is to make things that can kill the player.

When you die, there are different ways you can deal with it. You could have the player start their next life at the same place as they died, and be invincible for a few seconds. You could have the player begin at their original starting point. We're going to implement this so that the player resumes at the same place they started the level.

Add this bit of code to PlayerClass::onLevelLoaded, to preserve our original location, and whatever constant force is applied to the player for later reference:

    %this.originalPosition = %this.getPosition();
    %this.originalConstantForce = %this.getConstantForce();
    %this.originalConstantForceGravitic = %this.getGraviticConstantForce();

Add this to the top of player.cs to control how long after the player dies we should wait until respawning.

$respawnTime = 2000;

Now add a couple methods to player.cs:

function PlayerClass::killPlayer(%this)
{
  %this.setVisible(false);
  %this.setConstantForce(0, false);
  %this.playerLeftStop();
  %this.playerRightStop();
  moveMap.pop();
  %this.schedule($respawnTime, spawnPlayer);
}

function PlayerClass::spawnPlayer(%this)
{
  moveMap.push();  
  %this.setPosition(%this.originalPosition);
  %this.setConstantForce(%this.originalConstantForce, %this.originalConstantForceGravitic);
  %this.setVisible(1);
}

The reason we disable the constant force when the player is killed, and then re-enable it when they are respawned is because if we don't, then the camera will probably jerk around a bit which probably isn't what we want.

One last thing is to make a little change to PlayerClass::onLevelLoaded. Replace:

  moveMap.push();  

With:

  %this.spawnPlayer();

Save, hit play. Now, bring up the console and type:

$player.killPlayer();

The player will disappear, and after two seconds, reappear at the original location.

Now, we need things that can kill the player. This involves -- you guessed it -- another datablock, and another class. Add this to main.cs and restart TGB:

if(!isObject(FatalObjectTemplate)) new t2dSceneObjectDatablock(FatalObjectTemplate)
{
  class="FatalObjectClass";
  CollisionActiveSend = "1";
  CollisionActiveReceive = "1";
  CollisionCallback = "1";  
  CollisionPhysicsSend = "0";
  CollisionPhysicsReceive = "0";
};

We don't need any special fields here, we just want to make sure collision is enabled, physics are not (we wouldn't want to bump the deadly spikes over just because we ran into them!), and that the class is set.

In level.cs, let's add this:

function FatalObjectClass::onCollision(%this, %dest)
{
  if (%dest.class $= "PlayerClass")
    %dest.killPlayer();
}

It's very straight-forward. If the player touches an object with a class of FatalObjectClass, the player is killed. Let's add an object into our level that kills the player and try it out! Create a t2dStaticSprite, use a Config Datablock of "FatalObjectTemplate", save, and hit play. Now touch the object.

Pause

TGB provides us a mechanism to help with implementing pause, so let's make use of it.

In game.cs, we're going to add this, right before moveMap.push():

   moveMap.bindCmd(keyboard, "p", "togglePause();", "");

Elsewhere in that file, we're going to add this function:

function togglePause()
{
  %sg = sceneWindow2d.getSceneGraph();
  %sg.setScenePause(!%sg.getScenePause());
}

The scene graph has a notion of being paused. Basically, all physics are suspended when the scene is paused. Very handy.

Hit save, hit play, and try it out: Jump up, and hit p. Your character will hang around in mid air. But, something isn't quite right -- if you're on level 2 and you hit pause you'll STILL get switched to level 1, even while the game is paused. Why is this? Remember how we did the level-change timer?

    sceneWindow2D.schedule(%this.sceneDuration, loadLevel, %level);

We're calling schedule, and schedule waits for nothing! We must either find a way to avoid using schedule, or we must determine how much time remains before the event is to be fired when the scene is paused, cancel the event, and reschedule it for the remaining time afterwards. In this case, it's easier for us to just not use schedule.

In fact, generally speaking, using schedule is a bad idea. It makes for a scattered flow of control that's hard to follow, hard to debug, and hard to modify. There's almost always a better way to do it. In this case, we want to keep track of the passage of time from onUpdateScene. Unfortunately, we've already got a t2dSceneGraph::onUpdateScene method for updating the player and camera. We could just add a little more code there, but that becomes hard to maintain so we're going to refactor things a little bit and give ourselves a general mechanism for updating objects when the scene is updated. If you want to get geeky about it, what we're going to do is implement the "Observer Design Pattern".

We're going to start by removing the t2dSceneGraph::onUpdateScene method from player.cs entirely. While we're at it, we're going to change:

function PlayerClass::updateMovement(%this)

To:

function PlayerClass::onUpdateScene(%this)

And add this to PlayerClass::onLevelLoaded:

    %scenegraph.updateList.add(%this);

Then, in game.cs we're going to add this:

function t2dSceneGraph::onLevelLoaded(%this)
{
  %this.updateList = new SimSet();
}

function t2dSceneGraph::onUpdateScene(%this)
{
  if (isObject(%this.updateList)) 
  {
    for(%i = 0; %i < %this.updateList.getCount(); %i++)
    {
      %tmp = %this.updateList.getObject(%i);
      if(isObject(%tmp) && %tmp.getEnabled())
        %tmp.onUpdateScene();
    }
  }
}

What we're doing is creating a little container to hold objects that should be notified about scene updates. Then, we're adding the player to it. We're going to use this mechanism with our LevelEndTimerClass as well.

In level.cs, replace LevelEndTimerClass::onLevelLoaded with the following code:

function LevelEndTimerClass::onLevelLoaded(%this, %scenegraph)
{
  %scenegraph.updateList.add(%this);  
  %this.startTime = %this.getSceneGraph().getSceneTime();
}

function LevelEndTimerClass::onUpdateScene(%this)
{
  %currentTime = %this.getSceneGraph().getSceneTime();
  %elapsed = mRound((%currentTime - %this.startTime) * 1000);
  
  if (%elapsed >= %this.sceneDuration)
  {
    %level = $basePath @ %this.nextLevel;
    if (isFile( %level ) || isFile( %level @ ".dso"))
      sceneWindow2D.schedule(10, loadLevel, %level);
  }  
}

Our onLevelLoaded method adds itself to the list, and makes a note of the current time. You may be wondering why we're not calling getSimTime(), or getRealTime() -- and by extension, what exactly getSceneTime is. Well, the scene graph keeps track of its own notion of time, and, very importantly, this number does not increment when the scene is paused. So when we want to say "do something after X amount of time has passed", but we want to be able to pause, using the scene graph's notion of time is a very easy way of doing this.

A point to be aware of: Our onUpdateScene method will only be called when the scene isn't paused, and how much time has elapsed since the last call will tend to vary quite a bit. (This isn't terribly import here, but it's a good thing to know for future reference!)

So what we're doing is measuring how much time has elapsed since the last call to onUpdateScene, and seeing how much scene time has passed since the level was loaded. If that time is at least the length of time that's needed, we load the next level. We're still using schedule here to avoid crashing TGB. Since we're only scheduling for 10ms, it's not significant that this isn't paused when the user hits pause.

The last thing to note is that we're multiplying the elapsed time by 1000. The reason for this is that time is normally treated as milliseconds in TGB -- our "10000" default value for this class was meant to be 10 seconds. Also getSimTime and getRealTime return an integer representing milliseconds. Unfortunately, getSceneTime is different. It returns a floating point number that represents seconds. So, we multiply by 1000 to get milliseconds and round it off since we don't care about fractions of a millisecond.

There's also one more little problem: Hit pause, then hit jump and the character moves up slightly. Hit unpause, and the character flies up in the air. The very slight move that's visible during the pause is due to our physics hack. This also introduces a substantial cheat: You can move left or right without being subjected to gravity by pressing the left or right key repeatedly while paused. Great way to get across large chasms that are tricky to jump. Fortunately we don't want any actions taken while paused to actually result in game actions, and this fact makes it easy to fix both problems.

We're going to fix both problems by adding a small snippet of code to the very top of PlayerClass::playerLeft, PlayerClass::playerRight, and PlayerClass::playerJump in player.cs. Note that we are NOT adding the code to PlayerClass::playerLeftStop, or PlayerClass::playerRightStop.

  if (%this.getSceneGraph().getScenePause())
    return;

This code is simple: If the scene graph is paused when the player hits left, right, or jump then act as if they didn't press the button. The reason we don't do the same thing for the stop methods is to handle the situation of the player pressing and holding left/right, pausing, releasing left/right, then unpausing. If the stop was ignored during pause, then the player would continue moving once unpaused until the player pressed and released left/right again.

We've now got a game we can pause, but with it we have the responsibility to make sure that all the code we right going forward is pause-friendly.

Making it Look Better

What platformer is complete without parallax scrolling? Parallax scrolling, if you haven't heard the term, is where you have multiple background layers scrolling at different speeds, giving the illusion of depth. Mountains in the background move slowly but the grass just behind the player moves more quickly.

For the sake of simplicity, we're only going to worry about scrolling in the X dimension, and only when followX is used -- not when mountCamera is used. It's an exercise for the reader to generalize this to the Y dimension.

Our first step will be another template datablock in main.cs:

if(!isObject(ParallaxLayerTemplate)) new t2dSceneObjectDatablock(ParallaxLayerTemplate)
{
  class="ParallaxLayerClass";
};

Why are we going to the trouble of creating this template if the only attribute we're setting is the class? Why not just let the user type in the class name? Simple: Usability, and discoverability. From a usability standpoint, a dropdown list is better than a text box here because typos can lead to annoying "why isn't my background scrolling?!" problems. From a discoverability standpoint, if you just have a text box where they have to enter class names, how will they even know what classes are available? They may not know what "ParallaxLayerTemplate" is all about, but they'll know it exists and can dig through the docs to find what it means.

The next step is to create another list object on the scene graph where our parallax layers can register themselves. Add this to t2dSceneGraph::onLevelLoaded in game.cs:

  %this.parallaxList = new SimSet();

Now, here's the magic. Add this block in PlayerClass::updateHorizontal, inside, but at the end of the block that begins 'if(%this.cameraRangeX !$= "")':

        if (isObject(%this.getSceneGraph().parallaxList)) 
        {
          for(%i = 0; %i < %this.getSceneGraph().parallaxList.getCount(); %i++)
          {
            %tmp = %this.getSceneGraph().parallaxList.getObject(%i);
            if(isObject(%tmp) && %tmp.getEnabled())
              %tmp.setPositionX(calculateParallax(%this.getSceneGraph().getCameraSizeX(), %minX, %maxX, %playerX, %tmp.getSizeX()));
          }
        }

Just like our onUpdateScene mechanism, we're going to traverse this list and tweak each object, only this time we're doing it based on what the camera is doing. We're calling a function named calculateParallax that we haven't written yet. Let's add that, and the ParallaxLayerClass::onLevelLoaded method that we need (to add the layer to the list) in a new file called parallax.cs:

function ParallaxLayerClass::onLevelLoaded(%this, %scenegraph)
{
  %scenegraph.parallaxList.add(%this);  
}

function calculateParallax(%cSizeX, %minX, %maxX, %playerX, %lSizeX)
{
  %cRange = %maxX - %minX;
  %travelDistancePct = (%playerX - %minX) / %cRange;
  
  %delta = %lSizeX - %cSizeX;
  %pMinX = %minX + (%delta / 2);
  %pMaxX = %maxX - (%delta / 2);
  %pRange = %pMaxX - %pMinX;
  %newX = %pMinX + (%pRange * %travelDistancePct); 
          
  return %newX;
}

We also need to add an exec in game.cs, with all our other execs:

   exec("~/gameScripts/parallax.cs");

Let's stop and look at calculateParallax for a minute, and see what's going on.

Our level has a certain width -- in our demo, it's two full screens wide, or 400 units. The camera will move from -100 to 100 with the player, a range of 200 units. A parallax layer is going to be wider than the camera (200 units) and we want 200 units of it to be visible at all times, such that when we're at the left-most camera position we see the left-most portion of the parallax layer, and when we're at the right-most portion of the camera position we see the right-most portion of the parallax layer, and we smoothly scroll between these positions as the camera moves. If the parallax layer is narrower than the width of the level (400 units), it will move "slower" than the player. If it's wider than the level, it will move "faster" than the player. Thus, narrower layers are handy for backgrounds (things behind the player) and wider layers are handy for foregrounds (things in front of the player).

It doesn't matter where along the x axis you position your layer, but you should make sure the Y position is where you want the layer to be. This is because we will instantly correct the X position as soon as the level begins but we never change the Y position.

The way this math works is that we determine two important things:

  • How far along the X axis (as defined by the allowed camera range) the player is, as a percentage of distance. (%travelDistancePct)
  • What the minimum and maximum X values are for the parallax layer.

Once we've found these values, we basically say "move %travelDistancePt percent of the distance from %pMinX to %pMaxX. That's all there is to it. So, to recap, all you need to do to have a parallax layer is to create a t2dStaticSprite that is at least as wide as the camera, position it vertically where you want it, and use the ParallaxLayerTemplate config datablock -- if you're using followX on the player object.

   -Animating the Player.
   -Spawn animation.
   -Death animation.
   -Level-start animation.
   -Level-end animation.

Moving Platforms, and Ramps

   -Using objects mounted on paths.
   -Physics quirks to work around.
     -Tracking the platform when moving side-to-side.
     -Jumping from a downward-moving platform.
     -Sliding off ramps.
   -Time to stop using physics engine for gravity and start doing it ourselves?

Keeping Track of Score

   -Keeping the running total.
   -High score list.

Picking up Objects

   -Things you collect.
   -Things you don't collect.
       -Things that add to your score.
       -Things that add to your remaining-life count.

Baddies

   -Normal baddies.
   -Boss baddies.
       -Boss baddies that don't cause level transitions.
       -Boss baddies that cause level transitions.

End of Game

Where to Go From Here

 -Taking a step back/general design patterns.
   -Event driven model.
 -General DOs and DON'Ts.
   -Level-specific code.
 -Ideas for things to add, and how to approach them.
   -Power-ups.
   -Health counter.
   -Environmental effects.
   -Sound.
   -Intermittent environmental effects (geysers/steam jets/etc....).
   -Other physics-based interactions.