Artificial Intelligence Basic Frameworks · 1315 days ago
A while ago I mentioned that I was working on AI for UnEarthed Gods and how much fun I was having. I thought I’d talk a little bit more about what I did to get some flexible AI into the Torque Engine.
I started off with a few simple goals:
- add new AI quickly, without lots of coding (now isn’t that a great goal!)
- re-use existing behaviors for new creatures (like sheep that hunt, or wolves that play)
- mix and match (like villagers that hunt for sheep and play with wolves)
Starting out with the TGE 1.3 codebase there was already an AIPlayer class, but with precious little AI in it. You could hand make a path and have the player follow that path, you could have the player aim and something and pull a trigger to fire whatever weapon they are holding. This was already set up in the demo app so it was a start.
It was a fairly organic and meandering process of tweaking things to get to the final point, but I’ll talk about it as though it was straight forward from the beginning.
The first thing that was needed was some sort of generic AI thinking loop that could be applied to the majority if not all of my creatures.
something like:
- Perceive the world around you
- Analyze what you perceived
- Choose an appropriate action/state
- Execute the chosen action/state
This is about as basic as you can get, it’s the Reflexive-Agent-With-State from Artificial Intelligence: A Modern Approach
In order to meet my first goal of not having to write a lot of code when adding new creatures I need to keep all code that is common in the base AIPlayer class. This is standard OO programming practice nothing new.
What sorts of things are common to all AI? Generic Functions such as checking the line of sight to something, finding the closest player character, finding the closest item of a given type, finding out if a target can see us, basic pathfinding, loading and creating specific AI entities in the game with random stats, and perceiving the world around us.
What sorts of things are not common to all AI? Anything that makes the AI unique; what it is interested in, the ranges of valid starting stats, what actions it can choose from, how it views the world, what is an enemy, and what is a friend.
I’ll go over my core ai Think loop in more detail now with some pseudo code.
AIPlayer::Think:
- cancel any other scheduled thinks
- if we are in the dead state, return
- if (global ai paused is true) schedule another think and return
- find distance to closest PC
- scale the frequency of this AIPlayer::Think loop based on that distance
- schedule the next occurrence of this Think function
- if distance to closest PC is within a maximum range let the AI think
- aiLastAction = aiAction
- aiAction = 0
- PerceiveWorld()
- AnalyzeWorld()
- ChooseAction()
- if we have a new action
- aiLastAction.ExitState()
- aiAction.EnterState()
- aiAction.Execute()
This Think loop gets used for all AI no matter what class it is. The first really interesting part of that loop is the PerceiveWorld function. This is also the same chunk of code no matter what AI class we are using.
AIPlayer::PerceiveWorld:
- clear our list of aiPerceivedObjects
- ask the specific type of ai for a bitmask of the sorts of things its interested in perceiving. This could be enemies, players, items, or anything else in the game world.
- for each object of that type within the specific ai’s sight Range
- ask the specific type of ai if it is really interested in it, this could change depending on the AI’s state and current action, that’s up to the non-generic portion of the ai though
- do a more detailed check to see if the AI is aware of the object. This involves a field of view check, a line of sight check and a hearing check.
- if that check passes, add the object to our aiPerceivedObjects list, and store the distance to that object for later use
- ask the specific type of ai if it is really interested in it, this could change depending on the AI’s state and current action, that’s up to the non-generic portion of the ai though
After this function is called we have a collection of all the objects in the world that our AI is interested in and is aware of.
The next important function is AnalyzeWorld. This is something that is specific to a type of AI. How a wolf analyzes the world will be different than how sheep analyze the world. So this will be a member of our specific ai type.
Here is an example of a simple sheep type
AISheep::AnalyzeWorld:
- find distance to our spawn point (we like to stay near our home field)
- we are interested in Predators, Food, and Friends, so zero out variables to store those in
- loop through our aiPerceivedObjects that we gathered when we Perceived the World
- fill in our variables for lastSeenPredator, lastSeenPrey, and lastSeenFood if the perceivedObject is one of those.
Now our happy sheep knows where any food is, any friends are, and any predators are in the world they are aware of. We just need to decide what to do with this information. We do this with our ChooseAction function which is specific to the sheep.
AISheep::ChooseAction:
- if we see a predator
**our action is the AIActionFleePredator
- else if we see food
**our action is the AIActionGotoFood
- else if we see a friend and we aren’t too far from home
- our action is the AIActionFlockwithFriends
- else, no friends, no food, no predators
- our action is the AIActionWanderSimple, this will just do some sort of simple wandering around to make the sheep look more alive. It could be simple Brownian Motion or some more complex foraging for food search.
The different ai actions let me accomplish my #2 and #3 goals of being able to “re-use existing behaviors for new creatures” and “mix and match”. Each action is simply a state in a state machine, you can enter it, you can exit it, and while you are in it you can execute it.
here is a simple action for going to food
//////////////////////////
// AIActionGotoFood
// expects:
// ai[LastSeenFood]
//
new ScriptObject(AIActionGotoFood){ };
RootGroup.add(AIActionGotoFood);
function AIActionGotoFood::EnterState(%this, %obj){ %obj.clearAim(); }
function AIActionGotoFood::ExitState(%this, %obj){ }
function AIActionGotoFood::Execute(%this, %obj)
{
}
All actions have the EnterState, ExitState, and Execute functions. In the header comments you can see that the action expects the ai to set the ai[LastSeenFood] variable. This is a contract and must be set by the ai if it chooses this action. This standardized naming of variables lets you mix and match the AIActions
There’s lots more to the AI code, but with this basic set up I’m able to add new creatures pretty quickly and re-use a lot of the simple and complex actions I create for one AI with a completely different AI.
-Clint
Just for fun, here’s a more complex AIAction
//////////////////////////
// AIActionFlockWithFriends
// expects:
// ai[LastSeenFriend]
// ai[LastSeenFriendDist]
//ai_WanderSimpleDist //how far our random move should go in meters
//ai_minDistAway
//ai_maxDistAway
//
new ScriptObject(AIActionFlockWithFriends){ };
RootGroup.add(AIActionFlockWithFriends);
function AIActionFlockWithFriends::EnterState(%this, %obj){ %obj.clearAim(); }
function AIActionFlockWithFriends::ExitState(%this, %obj){ }
function AIActionFlockWithFriends::Execute(%this, %obj)
{
//this simple action
//moves in the same heading as whatever nearby friends we have
//but stays a little bit away from them.
%minDistAway = %obj.ai_minDistAway;
%maxDistAway = %obj.ai_maxDistAway;
%secondsAheadGuess = 1;
%velMag = VectorLen(%obj.ai[LastSeenFriend].getVelocity());
if( %velMag <= 0.5 )
{
//I see my friend but he’s not moving,
//lets move on our own
%obj.brownianMotion(%obj.ai_WanderSimpleDist);
return;
}
%posOffset = VectorScale(%obj.ai[LastSeenFriend].getVelocity(), %secondsAheadGuess);
%guessPos = VectorAdd(%obj.ai[LastSeenFriend].position, %posOffset);
if(%obj.ai[LastSeenFriendDist] > %maxDistAway)
{
//angle in to meet him
%runTo = %guessPos;
}
else if(%obj.ai[LastSeenFriendDist] < %minDistAway)
{
//move away
%away = VectorSub(%obj.position, %obj.ai[LastSeenFriend].position );
%away = VectorScale(VectorNormalize(%away), %minDistAway);
%runTo = VectorAdd(%guessPos, %away);
}
else
{
//move with the same heading
%runTo = VectorAdd(%obj.position, %posOffset);
}
%obj.setMoveDestination( %runTo, false);
}
