Introduction

Torque 3D's stock demos and templates are setup for first person shooter (FPS) games. However, the engine has multiple camera modes that can change the perspective and how the game is controlled. In this tutorial, we are going to modify the camera and mouse controls to emulate different game types: Hack & Slash and RTS.

Some of the topics that will be covered are:

  • Advanced scripting
  • Camera manipulation
  • Simple AI
  • Object spawning
  • Mouse and keyboard input
  • Basic RTS and Hack & Slash mechanics


Before you begin, you should be familiar with the following guides. Make sure you have read these before proceeding:


  • TorqueScript Syntax
  • Camera Modes
  • World Editor Interface
  • Adding 3D Shapes
  • Material Editor
  • Decal Editor
  • Datablock Editor

Create A Fresh Project

We're going to start with a clean project, so open the Toolbox and create one using the Full Template.  This will give us a blank terrain to work with.


Name your new project RTS_Prototype and select the Full template.


When the project has been created, select your project (1), select the Empty Terrain mission (2) and open it in the World Editor (3).



The Full Template used for this tutorial project should contain base art and scripts needed to run the game, but we want to use some custom models. Start by CLICKING HERE to download a zip file containing the sample assets.


Copy the art folder in the zip file on top of your project's art file. This will add the models, their datablocks, and their materials to your project if they do not exist. When you are ready, continue reading to configure your mouse controls.



BoomBot.cs, which contains the  should be located in the game/art/datablocks folder (1).  The image for the building placement button should be in the game/art/gui folder (2).  The files associated with Boom Bot's model, textures and animations should be located in the game/art/shapes/actors/BoomBot folder (3).


The zip file adds two new models to the project. One is a simple, static building. The other is a custom player model called Boom Bot. While you can simply drag and drop these models in via the World Editor, we need to hook up a script file prepared specifically for Boom Bot.


To get Boom Bot fully working, open game/art/datablocks/datablockExec.cs Scroll down until you see the following code:


exec("./aiPlayer.cs");

Just below that line, add the following:


exec("./BoomBot.cs");


In Torsion it should look something like this:


Also, check your BoomBot datablock if you are not using the files from the download. It should look like this:


datablock PlayerData(BoomBotData : DefaultPlayerData)
{
   shapeFile = "art/shapes/actors/BoomBot/BoomBot.dts";

   boundingBox = "1.1 1.2 2.5";
   pickupRadius = "1.2";
};                

Next, in scripts/server/gameDM.cs we'll change the default player class and datablock.  In DeathMatchGame::initGameVars() make the following changes:

    $Game::defaultPlayerClass = "AiPlayer";
    $Game::defaultPlayerDataBlock = "BoomBotData";

This gives us the ability to tell our unit where to go and let the AI class handle getting it there. It also lets us have our default player be the BoomBot without having to specify it at the spawnpoint.


Camera Setup

First we're going to set up our camera mode framework. In scripts/server/commands.cs we're going to add some functions to set and toggle our camera modes. The following code can sit at the end of the script file:

// ----------------------------------------------------------------------------
// Camera commands
// ----------------------------------------------------------------------------

function serverCmdorbitCam(%client)
{
   %client.camera.setOrbitObject(%client.player, mDegToRad(20) @ "0 0", 0, 5.5, 5.5);
   %client.camera.camDist = 5.5;
   %client.camera.controlMode = "OrbitObject";
}
function serverCmdoverheadCam(%client)
{
   %client.camera.position = VectorAdd(%client.player.position, "0 0 30");
   %client.camera.lookAt(%client.player.position);
   %client.camera.controlMode = "Overhead"; 
}

function serverCmdtoggleCamMode(%client)
{
   if(%client.camera.controlMode $= "Overhead")
   {
      %client.camera.setOrbitObject(%client.player, mDegToRad(20) @ "0 0", 0, 5.5, 5.5);
      %client.camera.camDist = 5.5;
      %client.camera.controlMode = "OrbitObject";
   }
   else if(%client.camera.controlMode $= "OrbitObject")
   {
      %client.camera.controlMode = "Overhead"; 
      %client.camera.position = VectorAdd(%client.player.position, "0 0 30");
      %client.camera.lookAt(%client.player.position);
   }
}

function serverCmdadjustCamera(%client, %adjustment)
{
   if(%client.camera.controlMode $= "OrbitObject")
   {
      if(%adjustment == 1)
         %n = %client.camera.camDist + 0.5;
      else
         %n = %client.camera.camDist - 0.5;
      
      if(%n < 0.5)
         %n = 0.5;
         
      if(%n > 15)
         %n = 15.0;
         
      %client.camera.setOrbitObject(%client.player, %client.camera.getRotation(), 
        0, %n, %n);
      %client.camera.camDist = %n;
   }
   if(%client.camera.controlMode $= "Overhead")
   {
      %client.camera.position = VectorAdd(%client.camera.position, "0 0 " @ %adjustment);
   }
}

Additionally, in scripts/client/default.bind.cs we'll add some keybinds and utility functions to assist in controlling the camera:

function toggleCameraMode(%val)
{
   if (%val)
      commandToServer('toggleCamMode');
}

moveMap.bind( keyboard, "ctrl m", toggleCameraMode);

function mouseZoom(%val)
{
   if(%val > 0)
   {
      commandToServer('adjustCamera', -1);
   }
   else
   {
      commandToServer('adjustCamera', 1);
   }
}

moveMap.bind(mouse, "zaxis", mouseZoom);

Ensure that any other binds for zaxis and alt m are commented out to avoid conflicts.  Also, if the file scripts/client/config.cs exists it will need to be deleted before changes to default.bind.cs can take effect.


Mouse Setup

The following code will change the way mouse input affects movement and click interaction.


Mouse Cursor Toggling

Normally, the camera is controlled by an actor in FPS (aim) mode. To focus on just mouse and camera work, we need to change how the default camera is controlled. Open game/scripts/server/gameCore.cs. In function GameCore::preparePlayer(%game, %client), locate the following line:

%game.spawnPlayer(%client, %playerSpawnPoint);


Change this code by adding a third argument to the function call:

%game.spawnPlayer(%client, %playerSpawnPoint, false);


The function call being modified is GameCore::spawnPlayer(%game, %this, %spawnPoint, %noControl), located in game/core/scripts/server/gameCore.cs. The last two arguments determine the location of spawning (%spawnPoint) and whether or not the actor object controls the camera (%noControl). We need to address that next.

Immediately below the %game.spawnPlayer() function, add the following code:

   // Set camera to Overhead mode   
   commandToServer('overheadCam');

If you run the game, you will now be using an orbit camera instead of an FPS view controlled by the actor. Next, we need to be able to control the on/off state of the in-game mouse cursor. Open game/scripts/client/default.bind.cs. At the end of the file, add the following:

// Turn mouse cursor on or off
// If %val is true, the button was pressed in
// If %val is false, the button was released
function toggleMouseLook(%val)
{
   // Check to see if button is pressed
   if(%val)  
   {
      // If the cursor is on, turn it off.
      // Else, turn it on
      if(Canvas.isCursorOn())
         hideCursor();
      else
         showCursor();
   }
}

// Bind the function toggleMouseLook to the keyboard 'm' key
moveMap.bind(keyboard, "m", "toggleMouseLook");


Next, open your file browser and delete scripts/client/config.cs, if it exists. This file contains custom keybinds created for your game. It will override the variables and functions you add to default.bind.cs. However, if you delete this file and run your game, a new one will be regenerated with your updated keybinds.


If you start the game now, it will still default to a free flying (mouse look) camera. By hitting the 'm' key you will be able to toggle "mouse look" mode. If mouse look is on, you can control your view direction by moving the mouse. If it is off, you can move your cursor around on the screen. You can switch back to an actor controlled camera by pressing Alt + C.


We will go ahead and force the cursor to be on as soon as the level loads. Open game/art/gui/playGui.gui. You can edit .gui files just like any other script file. Look for the noCursor field. Make the following change to this field:

noCursor = "0";


Now that you've freed up the mouse from aiming duties, it's time to put it to other uses.


Placing Structures Using The GUI

First, open art/gui/PlayGui.gui and find new GuiControl(DamageHUD) toward the end of the file and remove the entire block of code (ensure that you do not delete the main block's closing brace - it's the one with the semi-colon after it).  It occupies the center of the screen when you are in play mode and it will block mouse clicks to that area.


Optionally, you can remove this control element using the Gui Editor.


Next, open the GUI Editor by pressing F10 or by clicking the GUI Editor button from the World Editor.


Open the Library tab, pull down the Buttons rollout,  then click and drag the GuiBitmapButtonCtrl onto the GUI to create a new button.


Select your new button and change the settings as follows:


Browse to your project's art/gui folder and select the orcburrow.png file for the button image.


If you close the GUI Editor ( F10 ) you should now see your button in the game UI.


Next, open scripts/gui/playGui.cs and add the following code at the end:

// onMouseDown is called when the left mouse
// button is clicked in the scene
// %pos is the screen (pixel) coordinates of the mouse click
// %start is the world coordinates of the camera
// %ray is a vector through the viewing 
// frustum corresponding to the clicked pixel
function PlayGui::onMouseDown(%this, %pos, %start, %ray)
{
    // If we're in building placement mode ask the server to create a building for
    // us at the point that we clicked.
    if (%this.placingBuilding)
    {
        // Clear the building placement flag first.
        %this.placingBuilding = false;
        // Request a building at the clicked coordinates from the server.
        commandToServer('createBuilding', %pos, %start, %ray);
    }
    else
    {
        // Ask the server to let us attack a target at the clicked position.
        commandToServer('checkTarget', %pos, %start, %ray);
    }
}

// This function is the callback that handles our new button.  When you click it
// the button tells the PlayGui that we're now in building placement mode.
function orcBurrowButton::onClick(%this)
{
    PlayGui.placingBuilding = true;
}


Then, in scripts/server/commands.cs add the following function to the end:


function serverCmdcreateBuilding(%client, %pos, %start, %ray)
{
    // find end of search vector
    %ray = VectorScale(%ray, 2000);
    %end = VectorAdd(%start, %ray);

    // set up to look for the terrain
    %searchMasks = $TypeMasks::TerrainObjectType;

    // search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    // If the terrain object was found in the scan
    if( %scanTarg )
    {
        // get the world position of the click
        %pos = getWords(%scanTarg, 1, 3);

        // Note:  getWord(%scanTarg, 0) will get the SimObject id of the object 
        // that the button click intersected with.  This is useful if you don't 
        // want to place buildings on certain other objects.  For instance, you 
        // could include TSStatic objects in your search masks and check to see 
        // what you clicked on - then don't place if it's another building.

        // spawn a new object at the intersection point
        %obj = new TSStatic()
        {
            position = %pos;
            shapeName = "art/shapes/building/orcburrow.dts";
            collisionType = "Visible Mesh";
            scale = "0.5 0.5 0.5";
        };

        // Add the new object to the MissionCleanup group
        MissionCleanup.add(%obj);
    }
}


If you run the game now, you should be able to click the button, then click on the ground to place a new orc burrow.


Optionally, you can remove the DamageHUD element from the PlayGui - it is not useful in a strategy game and it happens to block mouse clicks.


Mouse-Driven Input

Without FPS controls and player aiming, we need a new way to control the Player object. The best examples of a mouse driven game genre are RTS and Hack & Slash. Typically, these game types allow you to move and attack using the mouse buttons. Let's start with movement.


Player Spawning

At this point, we can spawn an AI player to stand in for the stock player using the default player class settings we've provided in the DeathMatchGame::initGameVars() method. This AI will be controlled by our mouse inputs. In addition, Torque 3D uses a simple spawn system which can be easily modified to spawn any kind of object (of any class).  This section will demonstrate how to select what type of player or NPC you would like to spawn at a particular spawn point.


Open Toolbox, select the empty terrain level, then click the World Editor button.


Once you are in the editor, locate the spawn sphere in the scene. It is represented by a green octahedron, which will display a green sphere when you click on it:


(click to enlarge)




You can also locate a spawn sphere by browsing the Scene Tree, under the PlayerDropPoints SimGroup:


Image:RTS_SpawnSphere2.jpg


If you have multiple spawn spheres, delete all except for one. We can control what type of actor is spawned by changing the properties of the remaining spawn sphere. Select the sphere, then change the spawnClass to AIPlayer and spawnDatablock to BoomBotData. Also, change the name of the spawn sphere to PlayerSpawn.  Technically this step is optional, but if you add other player types you will want to be able to specify who spawns where.  This mechanism allows you to do that with minimal effort.


Image:RTS_SpawnProperties.jpg

This basically replicates the change we made in script earlier, but only for this specific spawn point.  You could as easily used MyBossData for the spawnDatablock field and then that spawnpoint would spawn MyBoss objects.


Movement

Now that we have an AI player spawning in the game, we can send it commands. Open game/scripts/gui/playGui.cs. Add the function onRightMouseDown as follows:

// onRightMouseDown is called when the right mouse
// button is clicked in the scene
// %pos is the screen (pixel) coordinates of the mouse click
// %start is the world coordinates of the camera
// %ray is a vector through the viewing 
// frustum corresponding to the clicked pixel
function PlayGui::onRightMouseDown(%this, %pos, %start, %ray)
{   
   commandToServer('movePlayer', %pos, %start, %ray);
}


At the end of scripts/server/commands.cs add the following:

function serverCmdmovePlayer(%client, %pos, %start, %ray)
{
   //echo(" -- " @ %client @ ":" @ %client.player @ " moving");
   
   // Get access to the AI player we control
   %ai = %client.player;

   %ray = VectorScale(%ray, 1000);
   %end = VectorAdd(%start, %ray);

   // We want to allow the AI Player to walk on TSStatics, Interiors, Terrain, etc., so 
   // I broadened the search mask selection.
   %searchMasks = $TypeMasks::TerrainObjectType | $TypeMasks::StaticTSObjectType | 
       $TypeMasks::InteriorObjectType | $TypeMasks::ShapeBaseObjectType | 
       $TypeMasks::StaticObjectType;

   // search!
   %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

   // If the terrain object was found in the scan
   if( %scanTarg )
   {
      %pos = getWords(%scanTarg, 1, 3);
      // Get the normal of the location we clicked on
      %norm = getWords(%scanTarg, 4, 6);
      
      // Set the destination for the AI player to
      // make him move
      %ai.setMoveDestination( %pos );
   }
}


Save your script and run the game. You should now be able to direct the AI player to wherever you right-click on the terrain. This only works if you have mouse look disabled, and your cursor is present on screen.


Spawning Enemy Targets

Our player looks lonely and bored. We should give him some targets, and the means of disposing them. Open game/scripts/client/default.bind.cs, and add the following to the bottom of the file:

// Spawn an AI guy when key is pressed down
function spawnAI(%val)
{
   // If key was pressed down
   if(%val)
   {
      // Create a new, generic AI Player
      // Position will be at the camera's location
      // Datablock will determine the type of actor
      new AIPlayer() 
      {
         position = LocalClientConnection.camera.getPosition();
         datablock = "DefaultPlayerData";
      };
   }
}

// Bind the function spawnAI to the keyboard 'b' key
moveMap.bind(keyboard, b, spawnAI);


In the above code, a new example of accessing a client connection is shown. Instead of ClientGroup, the code uses LocalClientConnection. In a "single player" environment, you can use these two interchangeably. Due to Torque 3D's architecture, there will always be a server and at least one client connection.


The common practice for choosing which to use is as follows:

  • Accessing From A Client - Use LocalClientConnection. This will always access your connection, player, camera, etc.
  • Accessing From Server - Use ClientGroup.getObject(%index). Multiple connections to choose from. This is good for applying the same functionality to all connections, or isolating specific ones based on ID.


Again, do not forget to delete game/scripts/client/config.cs. You can run the game, then press the 'b' key to spawn stationary AI targets in the same position as your camera. If gravity is enabled, they will fall until they hit the terrain.


Attacking

Currently, we have a player we can control, and targets that can die. Let's give the player some combat skills. In game/scripts/server/commands.cs, add the following two functions to the bottom of the script:

function serverCmdcheckTarget(%client, %pos, %start, %ray)
{
   %player = %client.player;
   
   %ray = VectorScale(%ray, 1000);
   %end = VectorAdd(%start, %ray);

   // Only care about players this time
   %searchMasks = $TypeMasks::PlayerObjectType;

   // Search!
   %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

   // If an enemy AI object was found in the scan
   if( %scanTarg )
   {
      // Get the enemy ID
      %target = firstWord(%scanTarg);
      if(%player != %target)
      {
         // Cause our AI object to aim at the target
         // offset (0, 0, 1) so you don't aim at the target's feet
         %player.setAimObject(%target, "0 0 1");
         
         // Tell our AI object to fire its weapon
         %player.setImageTrigger(0, 1);
      }
      else
      {
         serverCmdstopAttack(%client);
      }
   }
   else
   {
      serverCmdstopAttack(%client);
   }
}

function serverCmdstopAttack(%client)
{
    // If no valid target was found, or left mouse
    // clicked again on terrain, stop firing and aiming
    %unit = %client.player;
    %unit.setAimObject(0);
    %unit.schedule(150, "setImageTrigger", 0, 0);
}


Notice that we're using the full function name when calling serverCmdstopAttack() from within serverCmdcheckTarget(). Since we're in a serverCmd function we're not actually sending a command to the server because right now we are the server. So in this circumstance (and, coincidentally the same applies to clientCmd functions) we just call the full function directly. It is good to leave this as a serverCmd function because you might want the client to be able to request a unit (or all of his units) to stop attacking in some other situation.


Now, your player will continuously shoot at any other player you left click on (accuracy not guaranteed). Press the 'b' key to spawn targets to shoot at and blast away. The AI player will be locked in auto-fire mode until you left click on the terrain or on another target. or on another target.


We now have the base functionality for moving the player and the camera, selecting a target, and attacking is now complete.


Tweaking Attacks

You might have noticed some flaws with the base code:

  • The first shot usually misses
  • AI keeps shooting after enemy is dead
  • Enemy does not appear to "die" when health reaches 0


We are going to try and correct these one at a time using TorqueScript and the editors. Let's start by making our first shot be on target. The reason the first shot may miss entirely is because the AI is firing before it has fully turned to aim at the target.


To fix this, edit scripts/server/commands.cs., scroll down to the serverCmdcheckTarget() function, and locate the following line of code:

// Tell our AI object to fire its weapon
%player.setImageTrigger(0, 1);


Replace the above code with the following:

// Tell our AI object to fire its weapon in 100 milliseconds
%player.schedule(100, "setImageTrigger", 0, 1);
                


Remember, the %ai variable contains a handle to our AI player object. The AIPlayer object, which is a child class of SimObject, can make use of a method named schedule. Instead of calling the setImageTrigger function immediately, we can schedule it to go off in delayed manner.


Schedule (ConsoleMethod) Parameters

simObject.schedule(time, command, arg1...argN)
  • time - Number of milliseconds to wait before calling the command.
  • command - Member function (belonging to the simObject using schedule) to call
  • arg1...argN - Parameters, comma separated, to pass into the command.


The AI we control should now have time to turn and face the target before firing off the first shot. The code is currently delayed by 100 milliseconds, so you can adjust that number based on desired performance.


Next, we will change the auto-fire behavior. Instead of having the AI constantly attack a target, even after it is dead, we are going to modify the code to only cause our player to attack when a mouse button is clicked. In the same function we were just working in, locate the first schedule line we created

// Tell our AI object to fire its weapon in 100 milliseconds
%player.schedule(100, "setImageTrigger", 0, 1);


Then add the following directly under it:

// Stop firing in 150 milliseconds
%player.schedule(150, "setImageTrigger", 0, 0);


If you have not been saving after every script change, you should definitely do so. Save, then run your game to test the changes made to the attack routine. Your AI should now be facing the target on the first shot, and only attack when you click on the target.


There is one more change we can make to make the combat provide more feedback. Each enemy AI starts with health, which is diminished each time it gets shot. The Full template this tutorial is based on is originally intended for a FPS deathmatch game. When an actor dies, a death animation is played.


The death animation code can be found in game/scripts/server/player.cs. Open this file, then scroll down to the following function:


function Player::playDeathAnimation(%this)
{
if (isObject(%this.client))
   {
      if (%this.client.deathIdx++ > 11)
         %this.client.deathIdx = 1;
      %this.setActionThread("Death" @ %this.client.deathIdx);
   }
   else
   {
      %rand = getRandom(1, 11);
      %this.setActionThread("Death" @ %rand);
   }
}


The template uses Gideon as the stock actor. The model ships with 11 death animations, which are labeled as "death#" (where # is 1 - 11). This works well for a Player constantly dying, but for an AIPlayer in this tutorial, we only need 1 death animation. This tutorial also mainly works as a client side (single player) prototype.


In simpler terms, we do not need to use the death index (.deathIdx) or %client variables. We can simply call the first death animation available. Change the ::playDeathAnimation(...) function to the following:

function Player::playDeathAnimation(%this)
{
    %this.setActionThread("Death1");
}

Now, when the target AI loses all its health it will play a death animation and eventually disappear.


Next, you may notice that the AI units still have terrible aim. This is because the weapons are all set up to be used in first person mode. Any weapon that you intend to use with these AI units needs to be modified before they can be used effectively. Find your weapon datablock files in game/art/datablocks/weapons and search for LurkerWeaponImage (to use the Lurker datablock file as an example) then find useEyeNode and firstPerson. Change any instances of these from true to false. You can also experiment with correctMuzzleVector as well. You should find a block like the following, except at the end you will see some additions that should help.

   // Specify mount point & offset for 3rd person, and eye offset
   // for first person rendering.
   mountPoint = 0;
   firstPerson = false;
   useEyeNode = false;
   animateOnServer = true;

   // Add these to get BoomBot to aim correctly
   useEyeOffset = true;
   eyeOffset = "0 0 -0.35";


Fiddling with the eyeOffset vector may provide fine-tuning control over aiming, but I haven't been able to see any drastic difference. Don't be afraid to experiment.


As a bit of an aside, if you intend to be able to control units in a game with mixed AI and human players where the humans might be in first person control you will want to make a copy of all of your weapon datablocks and make the above modifications to them instead. You'll have to name your new datablocks to something like BotLurkerWeaponImage and load your AI players out with these versions of the weapons instead of the unmodified weapons that you use for the human players.


Destination Markers

In most RTS or Hack & Slash games, some kind of marker is placed on the ground where you clicked. This is usually a visual aid to let you know the move assignment was given, the destination has been set, and the AI is moving. .


We are going to add this functionality to our prototype to make it easier to track our AI player using the Material Editor, Decal Editor, and TorqueScript. First, we need to create a material for the marker.


Creating a Material

To get started on our marker creation, run your project in the World Editor. Next, open the Material Editor and click on the Create New Material button.


Image:MarkerNewMaterialButton.jpg


At this point, the current material will be switched to an orange warning texture signifying that no diffuse map has been applied. Change the Material name to "gg_marker" and press enter to apply the change. Next, click on the Diffuse Map box to open the file browser. Navigate to the game/art/decals folder and select the g_marker.png file. This asset was given to you at the beginning of this guide:


(click to enlarge)



Your new material is nearly complete. However, you should notice that the marker file and the material do not look the same. Compare the two:


Marker File

Image:g_marker.png


Material

Image:gMaterialBefore.jpg


This is easy to fix. While editing the gg_marker material, go to the very bottom in the Advanced Properties section and make the indicated changes:

Image:toggleAlphaThreshold.jpg


This will immediately change the material preview.  If you see something like the following, don't worry; this will probably look fine when we apply it to the decal.


You are finished with the material. Click save the save button, which will write out the following data to game/art/material.cs:

singleton Material(gg_marker)
{
   mapTo = "unmapped_mat";
   diffuseMap[0] = "art/markers/g_marker.png";
   alphaTest = "1";
   alphaRef = "80";
};


This is the benefit of using the visual editor to create your materials and decals, instead of manually writing them out in TorqueScript. Let's move on to creating the decal.


Creating a Decal

To create a marker decal, run the World Editor and then open the Decal Editor.

Click on the New Decal Data button ( ), next to the garbage bin ( ), and name your new entry "gg_decal".


Image:MarkerNewDecal.jpg


Next, click on the box in the Material Field of the decal properties, as shown below:


This should open the Material Selector. Locate the gg_maker material we created earlier, click on it, then press the Select button:


(click to enlarge)



The Decal Editor's preview box will display what your new decal will look like in the scene.


(click to enlarge)



That's all that needs to be done to create the decal. Save your level, and your decal data will automatically be written out to game/art/decals/managedDecalData.cs:

datablock DecalData(gg_decal)
{
   textureCoordCount = "0";
   Material = "gg_marker";
};

Spawning the Marker

Now that we have a destination marker, we need to add it upon clicking on the terrain and then delete it when our player reaches its destination. Start by opening game/scripts/gui/playGui.cs. Find the PlayGui::onRightMouseDown function. At the end of this function, add the following code:

     %ray = VectorScale(%ray, 1000);
    %end = VectorAdd(%start, %ray);

    // only care about terrain objects
    %searchMasks = $TypeMasks::TerrainObjectType | $TypeMasks::StaticTSObjectType | 
        $TypeMasks::InteriorObjectType | $TypeMasks::ShapeBaseObjectType |
        $TypeMasks::StaticObjectType;

    // search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    if (%scanTarg)
    {
        // Get access to the AI player we control
        %ai = LocalClientConnection.player;

        // Get the X,Y,Z position of where we clicked
        %pos = getWords(%scanTarg, 1, 3);

        // Get the normal of the location we clicked on
        %norm = getWords(%scanTarg, 4, 6);

        // If the AI player already has a decal (0 or greater)
        // tell the decal manager to delete the instance of the gg_decal
        if(%ai.decal > -1)
        {
            decalManagerRemoveDecal(%ai.decal);
        }

        // Create a new decal using the decal manager
        // arguments are (Position, Normal, Rotation, Scale, Datablock, Permanent)
        // AddDecal will return an ID of the new decal, which we will
        // store in the player
        %ai.decal = decalManagerAddDecal(%pos, %norm, 0, 1, "gg_decal", true);
    }


Save your script, then run the game. When you right click on the terrain, the GarageGames symbol should render as a decal at the destination.


(click to enlarge)




Erasing the Marker

The last thing we need to do is erase the destination marker when our AI player gets to it. Open the game/art/datablocks/BoomBot.cs file, then add the following:

// This is a callback function
// This is automatically called by the engine as part 
// of the AI routine
// %this - The BoomBotData datablock
// %obj - The instance of this datablock (our AI Player)
function BoomBotData::onReachDestination(%this, %obj)
{
   // If there was a decal placed, then it was
   // stored in this %obj variable (see playGui.cs)
   // Erase the decal using the decal manager
   if( %obj.decal > -1 )
      decalManagerRemoveDecal(%obj.decal);
}


Now, when the AI player reaches its destination the marker will be deleted.


Camera Modes

Now that you've got control of your character, it's time to discuss the camera controls. We've decided on a two-mode approach so that you can use the Overhead mode to observe the battlefield and the OrbitObject mode so that you can follow a specific unit.


Orbit Camera


Open the scripts/server/commands.cs script and find the serverCmdorbitCam() function:

function serverCmdorbitCam(%client)
{
   %client.camera.setOrbitObject(%client.player, mDegToRad(20) @ "0 0", 0, 5.5, 5.5);
   %client.camera.camDist = 5.5;
   %client.camera.controlMode = "OrbitObject";
}


Lets break this command down. %client.camera.setOrbitObject() puts the camera into OrbitObject mode. The first argument is the object to orbit around.  %client is provided by our caller when the server command is called. 


The second argument is a vector representing the angle of the camera in (x, y, z) or (pitch, roll, yaw) if you prefer. Here it is pitched 20 degrees down, with 0 roll and 0 yaw.


The next three arguments are the allowed distances from the target: min distance, max distance and current distance: here 0, 5.5 and 5.5 respectively. The last two arguments should be sent as floating point numbers or odd results can occur.  This function may take additional optional parameters: an ownership flag denoting if the object orbited by the camera belongs to the camera's client, an offset if the camera should focus somewhere other than the object's center, and a flag specifying if the camera should be locked.


The next line sets the camera distance from the orbit target to 5.5 units.


The final line sets the controlMode to OrbitObject.


Overhead Camera

Cameras used by RTS games are slightly different from the Hack & Slash or Fly cameras. They are characterized by a camera that moves laterally along the x and y axis, but generally not in z. This can be realized in T3D by using the "Overhead" camera mode.


In scripts/server/commands.cs find the serverCmdoverheadCam() function:

function serverCmdoverheadCam(%client)
{
   %client.camera.position = VectorAdd(%client.player.position, "0 0 30");
   %client.camera.lookAt(%client.player.position);
   %client.camera.controlMode = "Overhead"; 
}


With this setup, the camera will be free to move around with the standard "wasd" controls, but it will not move vertically in the world. By default the 'e' key should move the camera up and the 'c' key should move the camera down if you want to adjust the camera's height above the terrain.


For the traditional RTS players who wish to use the mouse wheel, we implemented mouse wheel zoom in this function from the Camera Setup section (scripts/server/commands.cs):

// Adjusts the height of the camera using the mouse wheel
function serverCmdadjustCamera(%client, %adjustment)
{
   if(%client.camera.controlMode $= "OrbitObject")
   {
      if(%adjustment == 1)
         %n = %client.camera.camDist + 0.5;
      else
         %n = %client.camera.camDist - 0.5;
      
      if(%n < 0.5)
         %n = 0.5;
         
      if(%n > 15)
         %n = 15.0;
         
      %client.camera.setOrbitObject(%client.player, %client.camera.getRotation(), 
        0, %n, %n);
      %client.camera.camDist = %n;
   }
   if(%client.camera.controlMode $= "Overhead")
   {
      %client.camera.position = VectorAdd(%client.camera.position, "0 0 " @ %adjustment);
   }
}

Notice that this function catches the camera mode and uses an appropriate method for adjusting the camera's position by checking the controlMode member's value.


In the above code, we are sticking to the client/server architecture of Torque 3D. Typically, actions such as navigating through GUIs, rendering, and input are handled on the client. However, when actions have an effect on the game, they should be performed on the server.


Camera location can usually be handled as a client operation, but this is a good opportunity to show off the client/server communication. Also, in multiplayer games it is important to remember that the server scopes visibility for the clients. Camera position should stay in sync to ensure that this scoping is accurate.  The default.bind.cs is a client script, which contains the client function mouseZoom(...). This is only called when there is a client action, such as the mouse wheel input.


Once the client action has been performed, a message is sent to the server to act on it: commandToServer('adjustCamera', -1);. The first parameter is the name of the server command/function to call (minus the serverCmd prefix), and the rest of the parameters are arguments used by the command. In this situation, based on the direction of the mouse wheel rotation a positive or negative 1 will be sent to the server command which uses this value to adjust the camera.


Now that the functions are set up, all that is left is creating a key bind to call them. Back in default.bind.cs we added the following binding to the script:

moveMap.bind( mouse, zaxis, mouseZoom );

This allows you to zoom in and out on your actor using your mouse's scroll wheel in orbit mode and adjust camera height in overhead mode..


If you want to play around with the camera settings created in this tutorial, examine the following code that we added at the bottom of game/scripts/server/commands.cs in the Camera Setup section.

function serverCmdorbitCam(%client)
{
   %client.camera.setOrbitObject(%client.player, mDegToRad(20) @ "0 0", 0, 5.5, 5.5);
   %client.camera.camDist = 5.5;
   %client.camera.controlMode = "OrbitObject";
}

function serverCmdoverheadCam(%client)
{
   %client.camera.position = VectorAdd(%client.player.position, "0 0 30");
   %client.camera.lookAt(%client.player.position);
   %client.camera.controlMode = "Overhead"; 
}

function serverCmdtoggleCamMode(%client)
{
   if(%client.camera.controlMode $= "Overhead")
   {
      %client.camera.setOrbitObject(%client.player, mDegToRad(20) @ "0 0", 0, 5.5, 5.5);
      %client.camera.camDist = 5.5;
      %client.camera.controlMode = "OrbitObject";
   }
   else if(%client.camera.controlMode $= "OrbitObject")
   {
      %client.camera.controlMode = "Overhead"; 
      %client.camera.position = VectorAdd(%client.player.position, "0 0 30");
      %client.camera.lookAt(%client.player.position);
   }
}


You can call these functions using the usual commandToServer syntax. Just type the following in the console (press ~)

commandToServer('orbitCam');

commandToServer('overheadCam');

Going More Real-Time Strategy

Here we're going to talk about what to do with that orc burrow.  We need a place to get more friendly units and the burrow seems the logical place, but it needs a few adjustments to work.  I'm not going into resource costs or build times - call them exercises for the reader - we're just going to pop out a new friendly unit when you left-click on the burrow.


First, we'll add some code to the serverCmdcheckTarget()

function serverCmdcheckTarget(%client, %pos, %start, %ray)
{
    %ray = VectorScale(%ray, 1000);
    %end = VectorAdd(%start, %ray);

    // Add new typemasks to the search so we can find clicks on barracks too
    %searchMasks = $TypeMasks::PlayerObjectType | $TypeMasks::StaticTSObjectType
         | $TypeMasks::StaticObjectType;

    // Search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    // If an enemy AI object was found in the scan
    if( %scanTarg )
    {
        // Get the enemy ID
        %target = firstWord(%scanTarg);
        if (%target.class $= "barracks")
        {
            serverCmdspawnTeammate(%client, %target);
        }
        else if (%target.getClassName() $= "AIPlayer")
        {
            if (%target.team != 1)
            {
                // Cause our AI object to aim at the target
                // offset (0, 0, 1) so you don't aim at the target's feet

                if (isObject(Team1List))
                {
                    %c = 0;
                    %unit = Team1List.getObject(0);
                    while (isObject(%unit))
                    {
                        if (%unit.isSelected)
                        {
                            %unit.mountImage(Lurker, 0);
                            %targetData = %target.getDataBlock();
                            %z = getWord(%targetData.boundingBox, 2) * 2;
                            %offset = "0 0" SPC %z;
                            %unit.setAimObject(%target, %offset);

                            // Tell our AI object to fire its weapon
                            %unit.setImageTrigger(0, 1);
                        }
                        %c++;
                        %unit = Team1List.getObject(%c);
                    }
                }
            }
            else
            {
                if ($SelectToggled)
                {
                    multiSelect(%target);
                }
                else
                {
                    cleanupSelectGroup();
                    %target.isSelected = true;
                    %target.isLeader = true;
                }
            }
        }
        else
        {
            serverCmdstopAttack(%client);
            if (!$SelectToggled)
                cleanupSelectGroup();
        }
    }
    else
    {
        serverCmdstopAttack(%client);
        if (!$SelectToggled)
            cleanupSelectGroup();
    }
}

I also added some support code for handling selection of multiple units and a little bit for spawning new bots from our orc burrows.


Add the following code in scripts/server/gameCore.cs at the end of the GameCore::spawnPlayer() function.

   %player.team = 1;

    if (!isObject(Team1List))
    {
        new SimSet(Team1List);
        MissionCleanup.add(Team1List);
    }
    
    Team1List.add(%player);


This ties in with our new multi-select system so that you can continue to select and control the original player object.


We'll need to revisit serverCmdstopAttack() to handle our multi-select system, too. It should look like this:

function serverCmdstopAttack(%client)
{
    // If no valid target was found, or left mouse
    // clicked again on terrain, stop firing and aiming
    for (%c = 0; %c < Team1List.getCount(); %c++)
    {
        %unit = Team1List.getObject(%c);
        %unit.setAimObject(0);
        %unit.schedule(150, "setImageTrigger", 0, 0);
    }
}


Next, we'll add some more server commands to help us with managing our army.   Our first step is to extend our serverCmdcreateBuilding() function to include a new spawn point that is associated with the structure to use as our troop creation point.  The new version should look like this:

function serverCmdcreateBuilding(%client, %pos, %start, %ray)
{
    // find end of search vector
    %ray = VectorScale(%ray, 2000);
    %end = VectorAdd(%start, %ray);

    %searchMasks = $TypeMasks::TerrainObjectType;

    // search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    // If the terrain object was found in the scan
    if( %scanTarg )
            %obj = getWord(%scanTarg, 0);

        %pos = getWords(%scanTarg, 1, 3);

        // spawn a new object at the intersection point
        %obj = new TSStatic()
        {
            position = %pos;
            shapeName = "art/shapes/building/orcburrow.dts";
            class = "barracks";
            collisionType = "Visible Mesh";
            scale = "0.5 0.5 0.5";
        };

        // Add the new object to the MissionCleanup group
        MissionCleanup.add(%obj);
        
        // Set up a spawn point for new troops to arrive at.
        if (!isObject(Team1SpawnGroup))
        {
            new SimGroup(Team1SpawnGroup)
            {
                canSave = "1";
                canSaveDynamicFields = "1";
                    enabled = "1";
            };

            MissionGroup.add(Team1SpawnGroup);
        }
        
        %spawnName = "team1Spawn" @ %obj.getId();
        %point = new SpawnSphere(%spawnName)
        {
            radius = "1";
            dataBlock      = "SpawnSphereMarker";
            spawnClass     = $Game::DefaultPlayerClass;
            spawnDatablock = $Game::DefaultPlayerDataBlock;
        };
        %point.position = VectorAdd(%obj.getPosition(), "0 5 2");
        Team1SpawnGroup.add(%point);
        MissionCleanup.add(%point);
    }
}

Next we'll add a function to spawn a new bot and equip it with a weapon and some ammo.

function serverCmdspawnTeammate(%client, %source)
{
    // Create a new, generic AI Player
    // Position will be at the camera's location
    // Datablock will determine the type of actor
    %spawnName = "team1Spawn" @ %source.getId();

    // Defaults
    %spawnClass      = $Game::DefaultPlayerClass;
    %spawnDataBlock  = $Game::DefaultPlayerDataBlock;

    // Overrides by the %spawnPoint
    if (isDefined("%spawnName.spawnClass"))
    {
     %spawnClass = %spawnName.spawnClass;
     %spawnDataBlock = %spawnName.spawnDatablock;
    }
    else if (isDefined("%spawnName.spawnDatablock"))
    {
     // This may seem redundant given the above but it allows
     // the SpawnSphere to override the datablock without
     // overriding the default player class
     %spawnDataBlock = %spawnName.spawnDatablock;
    }

    %spawnProperties = %spawnName.spawnProperties;
    %spawnScript     = %spawnName.spawnScript;

    // Spawn with the engine's Sim::spawnObject() function
    %newBot = spawnObject(%spawnClass, %spawnDatablock, "",
                        %spawnProperties, %spawnScript);

    %spawnLocation = GameCore::pickPointInSpawnSphere(%newBot, %spawnName);
    %newBot.setTransform(%spawnLocation);
    %newBot.team = 1;

    %newBot.clearWeaponCycle();

    %newBot.setInventory(Lurker, 1);
    %newBot.setInventory(LurkerClip, %newBot.maxInventory(LurkerClip));
    %newBot.setInventory(LurkerAmmo, %newBot.maxInventory(LurkerAmmo));
    %newBot.addToWeaponCycle(Lurker);

    if (%newBot.getDatablock().mainWeapon.image !$= "")
    {
        %newBot.mountImage(%newBot.getDatablock().mainWeapon.image, 0);
    }
    else
    {
        %newBot.mountImage(Lurker, 0);
    }
    
    // This moves our new bot away from the front door a ways to make room for 
    // other bots as we spawn them.
    %x = getRandom(-10, 10);
    %y = getRandom(4, 10);
    %vec = %x SPC %y SPC "0";
    %dest = VectorAdd(%newBot.getPosition(), %vec);
    %newBot.setMoveDestination(%dest);
    
    addTeam1Bot(%newBot);
}

We also need our addTeam1Bot() support function:

function addTeam1Bot(%bot)
{
    // We'll create a SimSet to track our Team1 bots if it hasn't been created already
    if (!isObject(Team1List))
    {
        new SimSet(Team1List);
        MissionCleanup.add(Team1List);
    }
    
    // And then add our bot to the Team1 list.
    Team1List.add(%bot);
}

At this point we're ready to spawn units from our structures.   If you test the game now, you should be able to create a new burrow and it should spawn bots when you click on it.



A real-time strategy game isn't much unless you can select and direct your units.  Next, we'll add a few more server commands and a client command to help with selecting and moving single and multiple units.


First we'll add the ability to "select" multiple units.   Still in scripts/server/commands.cs, add the following functions:

function serverCmdtoggleMultiSelect(%client, %flag)
{
    if (%flag)
        $SelectToggled = true;
    else
        $SelectToggled = false;
}

function multiSelect(%target)
{
    if (!isObject(Team1List))
    {
        new SimSet(Team1List);
        MissionCleanup.add(Team1List);
    }
    
    %leader = findTeam1Leader();
    if (isObject(%leader))
    {
        %target.destOffset = VectorSub(%leader.getPosition(), %target.getPosition());
    }
    else
    {
        %target.destOffset = "0 0 0";
        %target.isLeader = true;
    }

    %target.isSelected = true;
}

function findTeam1Leader()
{
    if (!isObject(Team1List))
    {
        new SimSet(Team1List);
        MissionCleanup.add(Team1List);
    }

    for (%c = 0; %c < Team1List.getCount(); %c++)
    {
        %unit = Team1List.getObject(%c);
        if (%unit.isLeader)
            return %unit;
    }

    return 0;
}

function cleanupSelectGroup()
{
    if (!isObject(Team1List))
    {
        new SimSet(Team1List);
        MissionCleanup.add(Team1List);
    }
    
    for (%c = 0; %c < Team1List.getCount(); %c++)
    {
        %temp = Team1List.getObject(%c);
        %temp.isSelected = false;
        %temp.isLeader = false;
        %temp.destOffset = "0 0 0";
    }
}

First, serverCmdtoggleMultiSelect() just takes a flag and sets a global variable to let the system know when we want to start adding units to our selection list.  The multiSelect() function actually handles setting up  the list by setting a member variable on the bot to indicate that it has been selected.  Additionally, if there is no other bot in the Team1List SimSet that is selected this bot is designated the "leader."  All of the subsequently selected bots will calculate offset destination targets based on this bot's destination.  The findTeam1Leader() utility function just searches the Team1List for a designated "leader" unit.  Finally, the cleanupSelectGroup() utility function just clears the selection variables from all of Team1List's members.


Next, modify PlayGui::onRightMouseDown() in scripts/gui so that it looks like the following:

function PlayGui::onRightMouseDown(%this, %pos, %start, %ray)
{   
    commandToServer('movePlayer', %pos, %start, %ray);
    
    %ray = VectorScale(%ray, 1000);
    %end = VectorAdd(%start, %ray);

    // only care about terrain objects
    %searchMasks = $TypeMasks::TerrainObjectType | $TypeMasks::StaticTSObjectType | 
    $TypeMasks::InteriorObjectType | $TypeMasks::ShapeBaseObjectType
    | $TypeMasks::StaticObjectType;

    // search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    if (%scanTarg)
    {
        %obj = getWord(%scanTarg, 0);

        // Get the X,Y,Z position of where we clicked
        %pos = getWords(%scanTarg, 1, 3);

        // Get the normal of the location we clicked on
        %norm = getWords(%scanTarg, 4, 6);

        // Create a new decal using the decal manager
        // arguments are (Position, Normal, Rotation, Scale, Datablock, Permanent)
        // We are now just letting the decals clean up after themselves.
        decalManagerAddDecal(%pos, %norm, 0, 1, "gg_decal", false);
    }
}

This change eliminates the need to track which bot is the "leader" and which bot which decal belongs to - it simply lets the decal expire.


In order to move our group of selected units together we'll have to update our serverCmdmovePlayer() function to tell all of our units where to go.

function serverCmdmovePlayer(%client, %pos, %start, %ray)
{
    //echo(" -- " @ %client @ ":" @ %client.player @ " moving");

    // Get access to the AI player we control
    %ai = findTeam1Leader();

    %ray = VectorScale(%ray, 1000);
    %end = VectorAdd(%start, %ray);

    // only care about terrain objects
    %searchMasks = $TypeMasks::TerrainObjectType | $TypeMasks::StaticTSObjectType | 
    $TypeMasks::InteriorObjectType | $TypeMasks::ShapeBaseObjectType | 
    $TypeMasks::StaticObjectType;

    // search!
    %scanTarg = ContainerRayCast( %start, %end, %searchMasks);

    // If the terrain object was found in the scan
    if( %scanTarg )
    {
        %pos = getWords(%scanTarg, 1, 3);
        // Get the normal of the location we clicked on
        %norm = getWords(%scanTarg, 4, 6);

        // Set the destination for the AI player to
        // make him move
        if (isObject(Team1List))
        {
            %c = 0;
            %end = Team1List.getCount();
            %unit = Team1List.getObject(0);
            while (isObject(%unit))
            {
                if (%unit.isSelected)
                {
                    %dest = VectorSub(%pos, %unit.destOffset);
                    %unit.setMoveDestination( %dest );
                }
                %c++;
                if (%c < %end)
                    %unit = Team1List.getObject(%c);
                else
                    %unit = 0;
            }
        }
        else
            %ai.setMoveDestination( %pos );
    }
}


Now we have to modify scripts/player/default.bind.cs to add some new functions and a new bind right after our bind to spawn enemy targets.

function addSelect()
{
    $SelectToggled = true;
    commandToServer('toggleMultiSelect', true);
}

function dropSelect()
{
    $SelectToggled = false;
    commandToServer('toggleMultiSelect', false);
}

moveMap.bindCmd( keyboard, "ctrl x", "addSelect();", "dropSelect();" );

Now we have ctrl-X bound to tell our system to toggle multi-selection via the addSelect() and dropSelect() functions on make and break respectively.  This key combination was chosen arbitrarily and you can of course use any key you like.  Note that at the moment shift only catches the "make" (in other words, it only catches the event when you press the key down) and not the "break," so if  you use it you'll have to write the function to toggle between multi-selection and single selection when shift is pressed.

If you test things now you should be able to select multiple units after you have spawned them and right-click to send them all off together.  Note that you will need to click somewhere very near the units' pelvis node to actually select them.



If you use the 'b' key to spawn a target and then left-click on it while multiple units are selected they will all attack the target.  You will need to left-click the terrain to stop them from attacking.  Left-clicking the terrain will also clear your current selection group.


Now open scripts/server/player.cs, find Armor::onDisabled() and add the following code after the call to %obj.playDeathAnimation():

   for (%i = 0; %i < Team1List.getCount(); %i++)
   {
       %unit = Team1List.getObject(%i);
       if (!isObject(%obj))
       {
           %unit.target = "";
           %unit.setAimObject(0);
           %unit.schedule(150, "setImageTrigger", 0, 0);
       }
   }

This will search our Team1List and stop any units who are currently attacking %obj (the dying object) to stop when %obj dies.


That about wraps it up. You should now have a pretty functional RTS prototype with unit control, unit spawning and some other basic features that are typical of the genre.


Conclusion

The purpose of this tutorial was to show you some of the more advanced capabilities of TorqueScript, and combine the language with Torque 3D's visual editors to create a prototype game. As you just experienced, getting a non-FPS prototype game started does not take long.


Make sure you have read through all the comments accompanying the new code, as they are part of the tutorial. At this point you can move on to other tutorials, or improve upon the code to create something more unique. There is always room for improvement, such as:

  • Changing the units' weapons out for rocket launchers or grenades
  • Make the targets move around and attack the player or the players units
  • Add key bindings to change camera modes on the fly

You can download the completed scripts by CLICKING HERE.

If you wish to download the scripts and assets in a single file, CLICK HERE.


A special thanks to Steve Acaster and his Tactics-Action Hybrid Game Tutorial series for saving me considerable time fiddling with the camera.