18 min read

In this article by David Young, author of Learning Game AI Programming with Lua, we will create reusable actions for agent behaviors.

(For more resources related to this topic, see here.)

Creating userdata

So far we’ve been using global data to store information about our agents. As we’re going to create decision structures that require information about our agents, we’ll create a local userdata table variable that contains our specific agent data as well as the agent controller in order to manage animation handling:

local userData =
{
   alive, -- terminal flag
   agent, -- Sandbox agent
   ammo, -- current ammo
   controller, -- Agent animation controller
   enemy, -- current enemy, can be nil
   health, -- current health
   maxHealth -- max Health
};

Moving forward, we will encapsulate more and more data as a means of isolating our systems from global variables. A userData table is perfect for storing any arbitrary piece of agent data that the agent doesn’t already possess and provides a common storage area for data structures to manipulate agent data. So far, the listed data members are some common pieces of information we’ll be storing; when we start creating individual behaviors, we’ll access and modify this data.

Agent actions

Ultimately, any decision logic or structure we create for our agents comes down to deciding what action our agent should perform. Actions themselves are isolated structures that will be constructed from three distinct states:

  • Uninitialized
  • Running
  • Terminated

The typical lifespan of an action begins in uninitialized state and will then become initialized through a onetime initialization, and then, it is considered to be running. After an action completes the running phase, it moves to a terminated state where cleanup is performed. Once the cleanup of an action has been completed, actions are once again set to uninitialized until they wait to be reactivated.

We’ll start defining an action by declaring the three different states in which actions can be as well as a type specifier, so our data structures will know that a specific Lua table should be treated as an action.

Remember, even though we use Lua in an object-oriented manner, Lua itself merely creates each instance of an object as a primitive table. It is up to the code we write to correctly interpret different tables as different objects. The use of a Type variable that is moving forward will be used to distinguish one class type from another.

Action.lua:
Action = {};
 
Action.Status = {
   RUNNING = "RUNNING",
   TERMINATED = "TERMINATED",
   UNINITIALIZED = "UNINITIALIZED"
};
 
Action.Type = "Action";

Adding data members

To create an action, we’ll pass three functions that the action will use for the initialization, updating, and cleanup. Additional information such as the name of the action and a userData variable, used for passing information to each callback function, is passed in during the construction time.

Moving our systems away from global data and into instanced object-oriented patterns requires each instance of an object to store its own data. As our Action class is generic, we use a custom data member, which is userData, to store action-specific information.

Whenever a callback function for the action is executed, the same userData table passed in during the construction time will be passed into each function. The update callback will receive an additional deltaTimeInMillis parameter in order to perform any time specific update logic.

To flush out the Action class’ constructor function, we’ll store each of the callback functions as well as initialize some common data members:

Action.lua:
function Action.new(name, initializeFunction, updateFunction,
       cleanUpFunction, userData)
 
   local action = {};
  
   -- The Action's data members.
   action.cleanUpFunction_ = cleanUpFunction;
   action.initializeFunction_ = initializeFunction;
   action.updateFunction_ = updateFunction;
   action.name_ = name or "";
   action.status_ = Action.Status.UNINITIALIZED;
   action.type_ = Action.Type;
   action.userData_ = userData;
      
   return action;
end

Initializing an action

Initializing an action begins by calling the action’s initialize callback and then immediately sets the action into a running state. This transitions the action into a standard update loop that is moving forward:

Action.lua:
function Action.Initialize(self)
   -- Run the initialize function if one is specified.
   if (self.status_ == Action.Status.UNINITIALIZED) then
       if (self.initializeFunction_) then
           self.initializeFunction_(self.userData_);
       end
   end
  
   -- Set the action to running after initializing.
   self.status_ = Action.Status.RUNNING;
end

Updating an action

Once an action has transitioned to a running state, it will receive callbacks to the update function every time the agent itself is updated, until the action decides to terminate. To avoid an infinite loop case, the update function must return a terminated status when a condition is met; otherwise, our agents will never be able to finish the running action.

An update function isn’t a hard requirement for our actions, as actions terminate immediately by default if no callback function is present.

Action.lua:
function Action.Update(self, deltaTimeInMillis)
   if (self.status_ == Action.Status.TERMINATED) then
       -- Immediately return if the Action has already
       -- terminated.
       return Action.Status.TERMINATED;
   elseif (self.status_ == Action.Status.RUNNING) then
       if (self.updateFunction_) then
           -- Run the update function if one is specified.          
           self.status_ = self.updateFunction_(
               deltaTimeInMillis, self.userData_);
 
           -- Ensure that a status was returned by the update
           -- function.
           assert(self.status_);
       else
           -- If no update function is present move the action
           -- into a terminated state.
           self.status_ = Action.Status.TERMINATED;
       end
   end
 
   return self.status_;
end

Action cleanup

Terminating an action is very similar to initializing an action, and it sets the status of the action to uninitialized once the cleanup callback has an opportunity to finish any processing of the action.

If a cleanup callback function isn’t defined, the action will immediately move to an uninitialized state upon cleanup.

During action cleanup, we’ll check to make sure the action has fully terminated, and then run a cleanup function if one is specified.

Action.lua:
function Action.CleanUp(self)
   if (self.status_ == Action.Status.TERMINATED) then
       if (self.cleanUpFunction_) then
           self.cleanUpFunction_(self.userData_);
       end
   end
  
   self.status_ = Action.Status.UNINITIALIZED;
end

Action member functions

Now that we’ve created the basic, initialize, update, and terminate functionalities, we can update our action constructor with CleanUp, Initialize, and Update member functions:

Action.lua:
function Action.new(name, initializeFunction, updateFunction,
       cleanUpFunction, userData)
  
   ...
 
   -- The Action's accessor functions.
   action.CleanUp = Action.CleanUp;
   action.Initialize = Action.Initialize;
   action.Update = Action.Update;
  
   return action;
end

Creating actions

With a basic action class out of the way, we can start implementing specific action logic that our agents can use. Each action will consist of three callback functions—initialization, update, and cleanup—that we’ll use when we instantiate our action instances.

The idle action

The first action we’ll create is the basic and default choice from our agents that are going forward. The idle action wraps the IDLE animation request to our soldier’s animation controller. As the animation controller will continue looping our IDLE command until a new command is queued, we’ll time our idle action to run for 2 seconds, and then terminate it to allow another action to run:

SoldierActions.lua:
function SoldierActions_IdleCleanUp(userData)
   -- No cleanup is required for idling.
end
 
function SoldierActions_IdleInitialize(userData)
   userData.controller:QueueCommand(
       userData.agent,
       SoldierController.Commands.IDLE);
      
   -- Since idle is a looping animation, cut off the idle
   -- Action after 2 seconds.
   local sandboxTimeInMillis = Sandbox.GetTimeInMillis(
       userData.agent:GetSandbox());
   userData.idleEndTime = sandboxTimeInMillis + 2000;
end

Updating our action requires that we check how much time has passed; if the 2 seconds have gone by, we terminate the action by returning the terminated state; otherwise, we return that the action is still running:

SoldierActions.lua:
function SoldierActions_IdleUpdate(deltaTimeInMillis, userData)
   local sandboxTimeInMillis = Sandbox.GetTimeInMillis(
       userData.agent:GetSandbox());
   if (sandboxTimeInMillis >= userData.idleEndTime) then
       userData.idleEndTime = nil;
       return Action.Status.TERMINATED;
   end
   return Action.Status.RUNNING;
end

As we’ll be using our idle action numerous times, we’ll create a wrapper around initializing our action based on our three functions:

SoldierLogic.lua:
local function IdleAction(userData)
   return Action.new(
       "idle",
       SoldierActions_IdleInitialize,
       SoldierActions_IdleUpdate,
       SoldierActions_IdleCleanUp,
       userData);
end

The die action

Creating a basic death action is very similar to our idle action. In this case, as death in our animation controller is a terminating state, all we need to do is request that the DIE command be immediately executed. From this point, our die action is complete, and it’s the responsibility of a higher-level system to stop any additional processing of logic behavior.

Typically, our agents will request this state when their health drops to zero. In the special case that our agent dies due to falling, the soldier’s animation controller will manage the correct animation playback and set the soldier’s health to zero:

SoldierActions.lua:
function SoldierActions_DieCleanUp(userData)
   -- No cleanup is required for death.
end
 
function SoldierActions_DieInitialize(userData)
   -- Issue a die command and immediately terminate.
   userData.controller:ImmediateCommand(
       userData.agent,
       SoldierController.Commands.DIE);
 
   return Action.Status.TERMINATED;
end
 
function SoldierActions_DieUpdate(deltaTimeInMillis, userData)
   return Action.Status.TERMINATED;
end

Creating a wrapper function to instantiate a death action is identical to our idle action:

SoldierLogic.lua:
local function DieAction(userData)
   return Action.new(
       "die",
       SoldierActions_DieInitialize,
       SoldierActions_DieUpdate,
       SoldierActions_DieCleanUp,
       userData);
end

The reload action

Reloading is the first action that requires an animation to complete before we can consider the action complete, as the behavior will refill our agent’s current ammunition count. As our animation controller is queue-based, the action itself never knows how many commands must be processed before the reload command has finished executing.

To account for this during the update loop of our action, we wait till the command queue is empty, as the reload action will be the last command that will be added to the queue. Once the queue is empty, we can terminate the action and allow the cleanup function to award the ammo:

SoldierActions.lua:
function SoldierActions_ReloadCleanUp(userData)
   userData.ammo = userData.maxAmmo;
end
 
function SoldierActions_ReloadInitialize(userData)
   userData.controller:QueueCommand(
       userData.agent,
       SoldierController.Commands.RELOAD);
   return Action.Status.RUNNING;
end
 
function SoldierActions_ReloadUpdate(deltaTimeInMillis, userData)
   if (userData.controller:QueueLength() > 0) then
       return Action.Status.RUNNING;
   end
  
   return Action.Status.TERMINATED;
end
SoldierLogic.lua:
local function ReloadAction(userData)
   return Action.new(
       "reload",
       SoldierActions_ReloadInitialize,
       SoldierActions_ReloadUpdate,
       SoldierActions_ReloadCleanUp,
       userData);
end

The shoot action

Shooting is the first action that directly interacts with another agent. In order to apply damage to another agent, we need to modify how the soldier’s shots deal with impacts. When the soldier shot bullets out of his rifle, we added a callback function to handle the cleanup of particles; now, we’ll add an additional functionality in order to decrement an agent’s health if the particle impacts an agent:

Soldier.lua:
local function ParticleImpact(sandbox, collision)
   Sandbox.RemoveObject(sandbox, collision.objectA);
  
   local particleImpact = Core.CreateParticle(
       sandbox, "BulletImpact");
   Core.SetPosition(particleImpact, collision.pointA);
   Core.SetParticleDirection(
       particleImpact, collision.normalOnB);
 
   table.insert(
       impactParticles,
       { particle = particleImpact, ttl = 2.0 } );
  
   if (Agent.IsAgent(collision.objectB)) then
       -- Deal 5 damage per shot.
       Agent.SetHealth(
           collision.objectB,
           Agent.GetHealth(collision.objectB) - 5);
   end
end

Creating the shooting action requires more than just queuing up a shoot command to the animation controller. As the SHOOT command loops, we’ll queue an IDLE command immediately afterward so that the shoot action will terminate after a single bullet is fired. To have a chance at actually shooting an enemy agent, though, we first need to orient our agent to face toward its enemy. During the normal update loop of the action, we will forcefully set the agent to point in the enemy’s direction.

Forcefully setting the agent’s forward direction during an action will allow our soldier to shoot but creates a visual artifact where the agent will pop to the correct forward direction. See whether you can modify the shoot action’s update to interpolate to the correct forward direction for better visual results.

SoldierActions.lua:
function SoldierActions_ShootCleanUp(userData)
   -- No cleanup is required for shooting.
end
 
function SoldierActions_ShootInitialize(userData)
   userData.controller:QueueCommand(
       userData.agent,
       SoldierController.Commands.SHOOT);
   userData.controller:QueueCommand(
       userData.agent,
       SoldierController.Commands.IDLE);
  
   return Action.Status.RUNNING;
end
 
function SoldierActions_ShootUpdate(deltaTimeInMillis, userData)
   -- Point toward the enemy so the Agent's rifle will shoot
   -- correctly.
   local forwardToEnemy = userData.enemy:GetPosition() –
       userData.agent:GetPosition();
   Agent.SetForward(userData.agent, forwardToEnemy);
 
   if (userData.controller:QueueLength() > 0) then
       return Action.Status.RUNNING;
   end
 
   -- Subtract a single bullet per shot.
   userData.ammo = userData.ammo - 1;
   return Action.Status.TERMINATED;
end
SoldierLogic.lua:
local function ShootAction(userData)
   return Action.new(
       "shoot",
       SoldierActions_ShootInitialize,
       SoldierActions_ShootUpdate,
       SoldierActions_ShootCleanUp,
       userData);
end

The random move action

Randomly moving is an action that chooses a random point on the navmesh to be moved to. This action is very similar to other actions that move, except that this action doesn’t perform the moving itself. Instead, the random move action only chooses a valid point to move to and requires the move action to perform the movement:

SoldierActions.lua:
function SoldierActions_RandomMoveCleanUp(userData)
 
end
 
function SoldierActions_RandomMoveInitialize(userData)
   local sandbox = userData.agent:GetSandbox();
 
   local endPoint = Sandbox.RandomPoint(sandbox, "default");
   local path = Sandbox.FindPath(
       sandbox,
       "default",
       userData.agent:GetPosition(),
       endPoint);
  
   while #path == 0 do
       endPoint = Sandbox.RandomPoint(sandbox, "default");
       path = Sandbox.FindPath(
           sandbox,
           "default",
           userData.agent:GetPosition(),
           endPoint);
   end
  
   userData.agent:SetPath(path);
   userData.agent:SetTarget(endPoint);
   userData.movePosition = endPoint;
  
   return Action.Status.TERMINATED;
end
 
function SoldierActions_RandomMoveUpdate(userData)
   return Action.Status.TERMINATED;
end
SoldierLogic.lua:
local function RandomMoveAction(userData)
   return Action.new(
       "randomMove",
       SoldierActions_RandomMoveInitialize,
       SoldierActions_RandomMoveUpdate,
       SoldierActions_RandomMoveCleanUp,
       userData);
end

The move action

Our movement action is similar to an idle action, as the agent’s walk animation will loop infinitely. In order for the agent to complete a move action, though, the agent must reach within a certain distance of its target position or timeout. In this case, we can use 1.5 meters, as that’s close enough to the target position to terminate the move action and half a second to indicate how long the move action can run for:

SoldierActions.lua:
function SoldierActions_MoveToCleanUp(userData)
   userData.moveEndTime = nil;
end
 
function SoldierActions_MoveToInitialize(userData)
   userData.controller:QueueCommand(
       userData.agent,
       SoldierController.Commands.MOVE);
  
   -- Since movement is a looping animation, cut off the move
   -- Action after 0.5 seconds.
   local sandboxTimeInMillis =
       Sandbox.GetTimeInMillis(userData.agent:GetSandbox());
   userData.moveEndTime = sandboxTimeInMillis + 500;
 
   return Action.Status.RUNNING;
end

When applying the move action onto our agents, the indirect soldier controller will manage all animation playback and steer our agent along their path.

Learning Game AI Programming with Lua

The agent moving to a random position

Setting a time limit for the move action will still allow our agents to move to their final target position, but gives other actions a chance to execute in case the situation has changed. Movement paths can be long, and it is undesirable to not handle situations such as death until the move action has terminated:

SoldierActions.lua:
function SoldierActions_MoveToUpdate(deltaTimeInMillis, userData)
   -- Terminate the action after the allotted 0.5 seconds. The
   -- decision structure will simply repath if the Agent needs
   -- to move again.
   local sandboxTimeInMillis =
       Sandbox.GetTimeInMillis(userData.agent:GetSandbox());
 if (sandboxTimeInMillis >= userData.moveEndTime) then
       userData.moveEndTime = nil;
       return Action.Status.TERMINATED;
   end
 
   path = userData.agent:GetPath();
   if (#path ~= 0) then
       offset = Vector.new(0, 0.05, 0);
       DebugUtilities_DrawPath(
           path, false, offset, DebugUtilities.Orange);
       Core.DrawCircle(
           path[#path] + offset, 1.5, DebugUtilities.Orange);
   end
 
   -- Terminate movement is the Agent is close enough to the
   -- target.
 if (Vector.Distance(userData.agent:GetPosition(),
       userData.agent:GetTarget()) < 1.5) then
 
       Agent.RemovePath(userData.agent);
       return Action.Status.TERMINATED;
   end
 
   return Action.Status.RUNNING;
end
SoldierLogic.lua:
local function MoveAction(userData)
   return Action.new(
       "move",
       SoldierActions_MoveToInitialize,
       SoldierActions_MoveToUpdate,
       SoldierActions_MoveToCleanUp,
       userData);
end

Summary

In this article, we have taken a look at creating userdata and reuasable actions.

Resources for Article:


Further resources on this subject:


LEAVE A REPLY

Please enter your comment!
Please enter your name here