RTS Prototype: Part 1

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 unit navigation
  • 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

NOTE: Changes between Torque 3D MIT 3.0 and 3.5 have broken this prototype.

If you are using Torque 3D MIT 3.5 or later you will need to be intimately familiar with Torque 3D and the changes that were made between the 3.0 and 3.5 releases. The premade scripts that are provided have been reported to function incorrectly when transplanted directly and would need extensive debugging to get them working properly.

You have been warned.

Create A Fresh Project

We're going to start with a clean project.  I usually do this manually using the following steps:

  1. Copy the Full folder from Templates to My Projects.
  2. Rename the Full folder. In this instance, rename it to RTSProto.
  3. Rename the game/Full.torsion file to match my project folder (RTSProto).
  4. Edit the buildFiles/config/project.conf file:
    1. Change the project name in the Torque3D::beginConfig line to match your project (RTSProto).
    2. Add the navigation module by adding includeModule( 'navigation' ); inside the config block.
  5. Run the generateProjects.bat file to generate the updated solution and project files.
  6. Build the project in your preferred compiler.

The Full Template used for this tutorial project should contain base art and scripts needed to run the game. We will be creating a few custom assets for the project as we go along.


First Steps

Navigation

We need to add a navigation mesh to our mission so that we know where we can place buildings and to allow units to path correctly around the mission area. Start the game and open Empty Terrain.mis in the World Editor. Once it is open, select the Mission Area Editor from the Editor rollout:

Next, adjust the mission area to fill most of the map by dragging the red corner nodes:

Then select the Navigation Editor from the Editor rollout:

In the New NavMesh dialog, set the name to NavEmptyTerrain and select both Fit NavMesh to mission area and Include terrain. Finally, click the Create! button.

Now we need to configure and build the navigation mesh. Set the name to NavEmptyTerrain, the fileName to levels/NavEmptyTerrain.nav, update the scale so that the third element (z) is around 500(just to be sure all objects added later are correctly added to the nav mesh), and the walkableSlope to 36 (our player datablock sets the maximum walkable slope to 38 degrees). Then select Play sound when done and click the Build NavMesh button. This will take a few minutes.

You should now have a navigation mesh that covers the entire mission area.

Loadout

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

    $Game::DefaultPlayerClass = "AiPlayer";
    $Game::DefaultPlayerDataBlock = "DemoPlayer";

This gives us the ability to tell our unit where to go and let the AI class handle getting it there.

Now open art/datablocks/aiPlayer.cs and update DemoPlayer to look like this:

datablock PlayerData(DemoPlayer : DefaultPlayerData)
{
   shootingDelay = 2000;
   mainWeapon = Lurker;
};

Next, in game/scripts/server/gameCore.cs find GameCore::loadOut() and comment out all of the weapons except the Lurker. Then mount the Lurker to slot 0 instead of the Ryder.

function GameCore::loadOut(%game, %player)
{
   //echo (%game @"\c4 -> "@ %game.class @" -> GameCore::loadOut");

   %player.clearWeaponCycle();
   
   //%player.setInventory(Ryder, 1);
   //%player.setInventory(RyderClip, %player.maxInventory(RyderClip));
   //%player.setInventory(RyderAmmo, %player.maxInventory(RyderAmmo));
   //%player.addToWeaponCycle(Ryder);

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

   //%player.setInventory(LurkerGrenadeLauncher, 1);
   //%player.setInventory(LurkerGrenadeAmmo, %player.maxInventory(LurkerGrenadeAmmo));
   //%player.addToWeaponCycle(LurkerGrenadeLauncher);

   //%player.setInventory(ProxMine, %player.maxInventory(ProxMine));
   //%player.addToWeaponCycle(ProxMine);

   //%player.setInventory(DeployableTurret, %player.maxInventory(DeployableTurret));
   //%player.addToWeaponCycle(DeployableTurret);
   
   if (%player.getDatablock().mainWeapon.image !$= "")
   {
      %player.mountImage(%player.getDatablock().mainWeapon.image, 0);
   }
   else
   {
      %player.mountImage(Lurker, 0);
   }
}                 

Now our character will spawn with only the lurker in its inventory and will equip it automatically.


Camera Setup

There are several camera modes available in Torque 3D that are generally unused because most people associate the engine with first person shooters. While the examples and general designe of the engine are pre-disposed toward this, there are many features that let us break out of that mold. Changing the camera modes is a good place to break out of the obvious FPS box.

A note on server and client command functions


There are several instances where you will use the commandToServer() and commandToClient() functions. The remote function you wish to call must be prefixed with "serverCmd" or "clientCmd". The first argument is the name of the remote command you wish to call without the related prefix, and that name must be quoted with single quotes. For example:

// a server command - these are usually placed in scripts/server/commands.cs
serverCmdmyCommand(%client){ // do something here }

// from a client-side script
commandToServer('myCommand'); // note ' instead of "

Additionally, you can generally separate client and server scripts by their folder locations. Client-side scripts are in scripts/client and scripts/gui (GUI scripts are client-side by nature) and server scripts are in scripts/server. Game assets and datablocks are all loaded server-side and verified client-side at game start. This also applies to the content of the core folder.



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 be added to the end of the script file:

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

function serverCmdorbitCam(%client)
{
    orbitCam(%client);
}

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

function serverCmdoverheadCam(%client)
{
    overheadCam(%client);
}

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

function serverCmdtoggleCamMode(%client)
{
    toggleCamMode(%client);
}

function toggleCamMode(%client)
{
    if(%client.camera.controlMode $= "Overhead")
        orbitCam(%client);
    else if(%client.camera.controlMode $= "OrbitObject")
        orbitCam(%client);
}

function serverCmdadjustCamera(%client, %adjustment)
{
    adjustCamera(%client, %adjustment);
}

function adjustCamera(%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.0)
            %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" SPC %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);

Now we have to track down references to Reticle and zoomReticle in scripts/client/default.bind.cs (while we're here) and scripts/client/client.cs just to clean up console errors:

// in scripts/client/default.bind.cs
function turnOffZoom()
{
   ServerConnection.zoomed = false;
   setFov(ServerConnection.getControlCameraDefaultFov());
   //Reticle.setVisible(true);
   //zoomReticle.setVisible(false);

   // Rather than just disable the DOF effect, we want to set it to the level's
   // preset values.
   //DOFPostEffect.disable();
   ppOptionsUpdateDOFSettings();
}
// in scripts/client/client.cs
function clientCmdRefreshWeaponHUD(%amount, %preview, %ret, %zoomRet, %amountInClips)
{
   if (!%amount)
      AmmoAmount.setVisible(false);
   else
   {
      AmmoAmount.setVisible(true);
      AmmoAmount.setText("Ammo: " @ %amount @ "/" @ %amountInClips);
   }

   if (%preview $= "")
      WeaponHUD.setVisible(false);//PreviewImage.setVisible(false);
   else
   {
      WeaponHUD.setVisible(true);//PreviewImage.setVisible(true);
      PreviewImage.setbitmap("art/gui/weaponHud/"@ detag(%preview));
   }

   if (%ret $= "")
   {
      // Add braces to avoid parse error - or remove this block entirely
      //Reticle.setVisible(false);
   }
   else
   {
      //Reticle.setVisible(true);
      //Reticle.setbitmap("art/gui/weaponHud/"@ detag(%ret));
   }

   if (isObject(ZoomReticle))
   {
      if (%zoomRet $= "")
      {
         ZoomReticle.setBitmap("");
      }
      else
      {
         ZoomReticle.setBitmap("art/gui/weaponHud/"@ detag(%zoomRet));
      }
   }
}

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/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 overhead 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 an overhead 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 the GUI Editor from the main menu by clicking the GUI Editor button, by pressing F10, or by clicking the GUI Editor button from within the World Editor.


Open the PlayGui gui by clicking the GUI collection dropdown and selecting it from the list.


Delete the DamageHUD element and its children. First, either select the Reticle object from the element tree on the right or click in the center of the GUI, then delete it.


Delete the Reticle and ZoomReticle objects using the same methods.


Now to add our simple build button. 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:


Save this image in your art/gui folder:


Browse to your project's art/gui folder and select the InfantryIcon.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 InfantryBld::onClick(%this)
{
    PlayGui.placingBuilding = true;
}


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

function serverCmdcreateBuilding(%client, %pos, %start, %ray)
{
    createBuilding(%client, %pos, %start, %ray);
}

function createBuilding(%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/station/station01.dts";
            collisionType = "Visible Mesh";
            scale = "0.5 0.5 0.5";
        };

        // find our nav mesh and update it so units will avoid the building
        // that we just placed:
        %navMesh = 0;
        %count = MissionGroup.getCount();
        for(%i = 0; %i < %count; %i++)
        {
            %missionObj = MissionGroup.getObject(%i);
            if(%missionObj.getClassName() $= "NavMesh")
            {
                %navMesh = %missionObj;
                break;
            }
        }
        if (%navMesh > 0)
            NavMeshUpdateOne(%navMesh, %obj);

        // 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 building.

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. 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)
{
    movePlayer(%client, %pos, %start, %ray);
}

function movePlayer(%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.setPathDestination( %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 of 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 spawnEnemy(%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
      %enemy = new AIPlayer() 
      {
         position = LocalClientConnection.camera.getPosition();
         datablock = "DemoPlayer";
      };
      %game.loadOut(%enemy);
   }
}
// Bind the function spawnEnemy to the keyboard 'b' key
moveMap.bind(keyboard, b, spawnEnemy);


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 to half unit height so you don't aim at the target's feet
            %datablock = %target.getDatablock();
            %offset = "0 0 "@%datablock.boundingBox.z / 2;
            %player.setAimObject(%target, %offset);
            %player.target = %target;

            // Tell our AI object to fire its weapon
            %player.setImageTrigger(0, 1);
        }
        else
        {
            stopAttack(%client);
        }
    }
    else
    {
        stopAttack(%client);
    }
}

function stopAttack(%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);
}

function serverCmdstopAttack(%client)
{
    stopAttack(%client);
}


Open scripts/server/weapon.cs and in WeaponImage::onFire() and update it to this:

function WeaponImage::onFire(%this, %obj, %slot)
{
    //echo("\c4WeaponImage::onFire( "@%this.getName()@", "@%obj.client.nameBase@", "@%slot@" )");

    // Make sure we have valid data
    if (!isObject(%this.projectile))
    {
        error("WeaponImage::onFire() - Invalid projectile datablock");
        return;
    }

    // Decrement inventory ammo. The image's ammo state is updated
    // automatically by the ammo inventory hooks.
    if ( !%this.infiniteAmmo )
        %obj.decInventory(%this.ammo, 1);

    // Get the player's velocity, we'll then add it to that of the projectile
    %objectVelocity = %obj.getVelocity();

    %numProjectiles = %this.projectileNum;
    if (%numProjectiles == 0)
        %numProjectiles = 1;

    if (%obj.isMemberOfClass("AIPlayer") && isObject(%obj.target))
    {
        %vec = %obj.getVectorTo(%obj.target.position);
        if(%obj.aimOffset)
            %vec = VectorAdd(%vec, "0 0 "@%obj.aimOffset);
        %vec = VectorNormalize(%vec);
    }
    else
    {
        // getting the straight ahead aiming point of the gun
        %vec = %obj.getMuzzleVector(%slot);
    }

    for (%i = 0; %i < %numProjectiles; %i++)
    {
        if (%this.projectileSpread)
        {
            // We'll need to "skew" this projectile a little bit.  We start by
            // Then we'll create a spread matrix by randomly generating x, y, and z
            // points in a circle
            %matrix = "";
            for(%j = 0; %j < 3; %j++)
                %matrix = %matrix @ (getRandom() - 0.5) * 2 * 3.1415926 * %this.projectileSpread @ " ";
            %mat = MatrixCreateFromEuler(%matrix);

            // Which we'll use to alter the projectile's initial vector with
            %muzzleVector = MatrixMulVector(%mat, %vec);
        }
        else
        {
            // Weapon projectile doesn't have a spread factor so we fire it using
            // the straight ahead aiming point of the gun
            %muzzleVector = %vec;
        }

        // Add player's velocity
        %muzzleVelocity = VectorAdd(
        VectorScale(%muzzleVector, %this.projectile.muzzleVelocity),
        VectorScale(%objectVelocity, %this.projectile.velInheritFactor));

        // Create the projectile object
        %p = new (%this.projectileType)()
        {
            dataBlock = %this.projectile;
        };
        %p.initialVelocity = %muzzleVelocity;
        %p.initialPosition = %obj.getMuzzlePoint(%slot);
        %p.sourceObject = %obj;
        %p.sourceSlot = %slot;
        %p.client = %obj.client;
        %p.sourceClass = %obj.getClassName();
        MissionCleanup.add(%p);
    }
}

Next, open scripts/server/aiPlayer.cs and add this method:

// Return position vector to a position
/// 
/// This function calculates the vector to %pos from eye point
/// 
/// The target position.
/// Returns a 3D vector from eye pos to target pos (not normalized).
function AIPlayer::getVectorTo(%this, %target)
{
    if (getWordCount(%pos) < 2 && isObject(%target))
        %pos = %target.getPosition();

    %z = getWord(%this.boundingBox, 2) / 2;
    %offset = "0 0" SPC %z;
    
    %vec = VectorAdd(%offset, %this.getPosition());

    %z = getWord(%target.boundingBox, 2) / 2;
    %offset = "0 0" SPC %z;
    
    %pos = VectorAdd(%offset, %pos);
    
    return VectorSub(%pos, %vec);
}


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 only fires a single shot at a time
  • Enemy may 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.

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

You can download this image to use by right-clicking and selecting "save image as" from the context menu.


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(DECAL_destDecal)
{
   mapTo = "unmapped_mat";
   diffuseMap[0] = "art/decals/destDecal.png";
   castShadows = "0";
   translucent = "1";
   translucentZWrite = "1";
   alphaTest = "1";
   alphaRef = "80";
   showFootprints = "0";
   materialTag0 = "Miscellaneous";
};

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.




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 scripts/client/client.cs. At the end of this file, add the following code:

function clientCmdsetDestDecal(%unit, %position, %norm)
{
    // If the AI player already has a decal (0 or greater)
    // tell the decal manager to delete the instance of the gg_decal
    if(%unit.decal > -1)
    {
        decalManagerRemoveDecal(%unit.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
    %unit.decal = decalManagerAddDecal(%position, %norm, 0, 1, "destDecal", true);
}


Then, in scripts/server/commands.cs, modify movePlayer() to tell the client to spawn the decal:

    // .... 
    // 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.setPathDestination( %pos );
        
        // add this!
        commandToClient(%client, 'setDestDecal', %ai, %pos, %norm);
    }


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.





Erasing the Marker

The last thing we need to do is erase the destination marker when our AI player gets to it. Open the scripts/server/aiPlayer.cs file, then add the following to the end of DemoPlayer::onReachDestination():

   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)
{
    orbitCam(%client);
}

function orbitCam(%client)
{
    %client.camera.setOrbitObject(%client.player, mDegToRad(20) SPC "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)
{
    overheadCam(%client);
}

function overheadCam(%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)
{
    adjustCamera(%client, %adjustment);
}

function adjustCamera(%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.0)
            %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" SPC %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)
{
    orbitCam(%client);
}

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

function serverCmdoverheadCam(%client)
{
    overheadCam(%client);
}

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

function serverCmdtoggleCamMode(%client)
{
    toggleCamMode(%client);
}

function toggleCamMode(%client)
{
    if(%client.camera.controlMode $= "Overhead")
        orbitCam(%client);
    else if(%client.camera.controlMode $= "OrbitObject")
        orbitCam(%client);
}

function serverCmdadjustCamera(%client, %adjustment)
{
    adjustCamera(%client, %adjustment);
}

function adjustCamera(%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.0)
            %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" SPC %adjustment);
}


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 station.  We need a place to get more friendly units and the station seems the logical place, but it needs a few adjustments to work.  I'm not going into resource costs or build times - we'll cover that in part 2 - we're just going to pop out a new friendly unit when you left-click on the station.


First, we'll overhaul GameCore::spawnPlayer() in scripts/server/gameCore.cs. We need to ditch the vestigial player spawn that comes with the FPS stuff and just drop the new player's camera directly at a spawn point. We're pretty much going to gut this since we don't need anything that has to do with setting up a player - just the client's camera.

function GameCore::spawnPlayer(%game, %client, %spawnPoint, %noControl)
{
    //echo (%game @"\c4 -> "@ %game.class @" -> GameCore::spawnPlayer");

    if (isObject(%client.player))
    {
        // The client should not already have a player. Assigning
        // a new one could result in an uncontrolled player object.
        error("Attempting to create a player for a client that already has one!");
    }

    // Attempt to treat %spawnPoint as an object
    if (getWordCount(%spawnPoint) == 1 && isObject(%spawnPoint))
    {
        // Defaults
        %spawnClass      = $Game::DefaultPlayerClass;
        %spawnDataBlock  = $Game::DefaultPlayerDataBlock;

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

        %spawnProperties = %spawnPoint.spawnProperties;
        %spawnScript     = %spawnPoint.spawnScript;

        %client.spawnCamera(%spawnPoint);
    }

    // If the player's client has some owned turrets, make sure we let them
    // know that we're a friend too.
    if (%client.ownedTurrets)
    {
        for (%i=0; %i<%client.ownedTurrets.getCount(); %i++)
        {
            %turret = %client.ownedTurrets.getObject(%i);
            %turret.addToIgnoreList(%player);
        }
    }

    if (!isDefined("%client.skin"))
    {
        // Get available skins
        %availableSkins = %spawnDataBlock.availableSkins; // TAB delimited list of skin names

        // Set skin based on client's team.  If client's team is not set, set it to 1
        if(%client.team $= "")
            %client.team = 1;
        %client.skin = addTaggedString( getField(%availableSkins, %client.team) );
    }

    if(!isObject(%client.TeamList))
    {
        %client.TeamList = new SimSet();
        MissionCleanup.add(%client.TeamList);
    }
    %client.TeamList.add(%player);

    // Give the client control of the camera if in the editor
    if( $startWorldEditor )
    {
        %control = %client.camera;
        %control.mode = "Fly";
        EditorGui.syncCameraGui();
    }
    else
    {
        %control = %client.camera;
        overheadCam(%client);
    }

    // Allow the player/camera to receive move data from the GameConnection.  Without this
    // the user is unable to control the player/camera.
    if (!isDefined("%noControl"))
        %client.setControlObject(%control);
    if (isObject(%spawnPoint))
        %control.setTransform(%spawnPoint.getTransform());
}


With this all we have to do is grab the spawn sphere in the mission editor and place it so that it is where we want our player camera to spawn. Open the mission in the Mission Editor and select the spawn sphere:



Now click Object in the menu, then find Drop Location > at Camera w/Rotation and click that.



Finally, click Object and then click Drop Selection.



The spawn sphere is now placed where the camera is and it is facing the same direction as the camera. When the player enters the mission his camera will be placed at the spawn sphere's location with its rotation.


Now, we'll add some code to the serverCmdcheckTarget() function to handle spawning. The function should look something like this:

function attack(%client, %unit)
{
    %target = %unit.getAimObject();
    if (%target.getState() $= "Dead")
    {
        %unit.setAimObject(0);
    }
    else
    {
        %unit.setImageTrigger(0, 1);
        %unit.schedule(64, "setImageTrigger", 0, 0);
        schedule(128, 0, "attack", %client, %unit, %target);
    }
}

function serverCmdcheckTarget(%client, %pos, %start, %ray)
{
	checkTarget(%client, %pos, %start, %ray);
}

function checkTarget(%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")
        {
            spawnTeammate(%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(%client.TeamList))
                {
                    %c = 0;
                    %unit = %client.TeamList.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
                            attack(%client, %unit);
                        }
                        %c++;
                        %unit = %client.TeamList.getObject(%c);
                    }
                }
            }
            else
            {
                if ($SelectToggled)
                {
                    multiSelect(%target);
                }
                else
                {
                    cleanupSelectGroup();
                    %target.isSelected = true;
                    %target.isLeader = true;
                }
            }
        }
        else
        {
            stopAttack(%client);
            if (!$SelectToggled)
                cleanupSelectGroup();
        }
    }
    else
    {
        stopAttack(%client);
        if (!$SelectToggled)
            cleanupSelectGroup();
    }
}

We've also added some support code for handling selection of multiple units and a little bit for spawning new bots from our stations.


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

function serverCmdstopAttack(%client)
{
    stopAttack(%client);
}

function stopAttack(%client)
{
    // If no valid target was found, or left mouse
    // clicked again on terrain, stop firing and aiming
    for (%c = 0; %c < %client.TeamList.getCount(); %c++)
    {
        %unit = %client.TeamList.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)
{
	createBuilding(%client, %pos, %start, %ray);
}

function createBuilding(%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/station/station01.dts";
            class = "barracks";
            collisionType = "Visible Mesh";
            scale = "0.5 0.5 0.5";
        };

        %navMesh = 0;
        %count = MissionGroup.getCount();
        for(%i = 0; %i < %count; %i++)
        {
            %missionObj = MissionGroup.getObject(%i);
            if(%missionObj.getClassName() $= "NavMesh")
            {
                %navMesh = %missionObj;
                break;
            }
        }
        if (%navMesh > 0)
            NavMeshUpdateOne(%navMesh, %obj);

        // 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)
{
    spawnTeammate(%client, %source);
}

function spawnTeammate(%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.setPathDestination(%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(%client.TeamList))
    {
        %client.TeamList = new SimSet();
        MissionCleanup.add(%client.TeamList);
    }
    
    // And then add our bot to the Team1 list.
    %client.TeamList.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 station 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(%client.TeamList))
    {
        %client.TeamList = new SimSet();
        MissionCleanup.add(%client.TeamList);
    }
    
    %leader = findTeamLeader(%client);
    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(%client.TeamList))
    {
        %client.TeamList = new SimSet();
        MissionCleanup.add(%client.TeamList);
    }

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

    return 0;
}

function cleanupSelectGroup()
{
    if (!isObject(%client.TeamList))
    {
        %client.TeamList = new SimSet();
        MissionCleanup.add(%client.TeamList);
    }
    
    for (%c = 0; %c < %client.TeamList.getCount(); %c++)
    {
        %temp = %client.TeamList.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 %client.TeamList 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 %client.TeamList for a designated "leader" unit.  Finally, the cleanupSelectGroup() utility function just clears the selection variables from all of %client.TeamList'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);
}


In order to move our group of selected units together we'll have to update our movePlayer() function in scripts/server/commands.cs to tell all of our units where to go.

function movePlayer(%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(%client.TeamList))
        {
            %c = 0;
            %end = %client.TeamList.getCount();
            %unit = %client.TeamList.getObject(0);
            if(%ai $= "")
                %ai = %unit;
            while (isObject(%unit))
            {
                if (%unit.isSelected)
                {
                    %dest = VectorSub(%pos, %unit.destOffset);
                    %unit.setPathDestination( %dest );
                }
                %c++;
                if (%c < %end)
                    %unit = %client.TeamList.getObject(%c);
                else
                    %unit = 0;
            }
        }
        else
        {
            if (%ai !$= "")
                %ai.setPathDestination( %pos );
        }
        
        commandToClient(%client, 'setDestDecal', %ai, %pos, %norm);
    }
}


Now we have to modify scripts/client/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.  The units should stop attacking when the target is dead.  Left-clicking the terrain will also clear your current selection group.



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


A special thanks to Michael Perry for the original RTS Prototype article and Steve Acaster for his Tactics-Action Hybrid Game Tutorial series, which saved me considerable time fiddling with the camera.


Continue in part 2.