Creatures, NPCs and AI Tutorial

From HEWIKI
Jump to: navigation, search

Contents

He advanced.png


This tutorial will cover the basics of getting NPCs out into your Areas and have them running some basic AI. Before we begin, you should be familiar with the information covered under Creatures and NPCs, $NPC, Character Data Tutorial and AI Agents. Those sections detail how to add new NPCs to your game, what overrides are provided by the HeroEngine NPC system, give you a high level overview of data storage options when dealing with characters and NPCs, and give a technical overview of the API for the AI Agent system.


NPCs vs. PCs

From a scripting perspective, how different NPCs and players are can be a lot or a little. HeroEngine does require NPCs use a base class that is, or derives from, _nonPlayerCharacter. The archetype for this class is CREATURE. This is the equivalent of a player's _PlayerCharacter node. Each of these nodes should have a _characterAppearance node hard associated to it, which defines the visual representation of the character.

There are a number of things built-in to the engine which require you to distinguish between players and NPCs, and it does so based on the node's class. A few examples: Triggers, which can detect players or NPCs or both, and several external functions like GetTargetsInBox which accept parameters for finding players or NPCs or both.

Beyond that, how your game systems interact with players versus NPCs is largely up to you. In Hero's Journey we've made the decision that the two should be fairly interchangeable for game systems. Combat, for example, uses a Combatant class which both players and NPCs have. All combat functions and methods operate on that Combatant class. It's only when you get to certain things (such as messaging combat results) that you actually bother to check if it's a player or not. Depending on your particular game systems and game design, however, you may wish to have explicitly different classes for players versus NPCs in some situations. It's entirely up to you to make these implementation decisions.

Dynamically Creating NPCs

You should already know that you can create an NPC on the fly using the /henpc command. When you create an NPC this way, you get a bare bones NPC: just the _nonPlayerCharacter node and its associated _characterAppearance. The _characterAppearance will be in the default configuration (for example if you're using the Dynamic Character System none of the slots or parts will be set). This basic NPC, therefore, will be lacking a lot of game system information (unless you've overridden NPC creation to add all of that stuff in, which is an option -- refer to $NPC). For Hero's Journey, we've opted to use our Spec System to define all of our NPCs. Whether you use that or another system is up to you. Ultimately you need to somewhere define all of your game-specific data for a particular NPC: combat stats, AI configuration, visual representation, etc.

Hero's Journey achieves this with an "NPC Spec Oracle" and nearly all of the needed data is mixed-in (via Glomming). We have separate glom classes to define different "outfits" (visual representations), classes that specify different AI behaviors, and so on. Each NPC is setup individually in the oracle. When we want to create an NPC, it is created from a spec. The Spec System documentation covers implementation of that pretty well, but again, you can use your own specialty system. For the remainder of this tutorial I will simply refer back to this mechanism as the NPC factory. It can have whatever input is appropriate for your game, and the output should always be an NPC.

Once you have a factory created, you need a system that causes NPCs to be created from the factory. Such a mechanism is generally called a spawner, mob spawn, or mongen. Hero's Journey uses a custom system with lots of options based on our game design requirements. Spawners don't have to be particularly complex, however. The basic idea is that the spawner knows what NPC to factory up (by spec or whatever you've implemented) and how many to create, where to place them, and how often to make them.

For an extremely simple spawner test case, you could use a Trigger. Implement the method TriggerEnter to call your factory. Assuming your trigger is setup properly, simply stepping into it will cause the NPC to be created.

Example:

method TriggerEnter(who as NodeRef)
  var specoracle = GetPrototype("NpcSpecOracle")  // get the spec oracle, for example
  var spec = specoracle.GetSpecByKey(3)   // you could store the spec ID somewhere, hard-coded for an example
  var npc = spec.CreateFromSpec()    // create the npc!
  npc._TeleportCharacter(me.Position, me.Rotation)  // put the npc where the trigger is
  npc._AgentWakeUp()  // wakes the agent up. assumes that the factory (CreateFromSpec() in this example) already called _RegisterAgent()
.

That is probably the most basic example of NPC creation. When it comes time to implementing beyond a test case, you'll find it gets more complicated as you want to track what spawned an NPC, perhaps notify the spawner when the NPC dies so it can create more NPCs, figuring out exactly when to spawn things (on something you have more control over than a player walking into a trigger!), etc.


NPCs as AI Agents

The built-in system for AI Agents is pretty easy to work with. To start with, you probably want to make your own abstract class which inherits from _AiAgent (the abstract class provided by HeroEngine). Having your own abstract class means being able to override HeroEngine methods in one place, instead of having to do the same override for all of your agent classes. This also gives you a single place to add methods which you expect all of your AI agents to implement. The agent class is intended to be glommed onto the NPC. This is something your NPC factory should handle.

Before getting into the agent logic, we need to briefly discuss AI states. Agent logic handles things like what to do when it has no state, determining if it can safely sleep (because no players are around, for example), and is usually the place for callbacks from elsewhere in the AI system. AI states, on the other hand, contain chunks of logic that make the agent "go" and perform whatever task they need to perform. Agent logic tends to be a bit more specific for different NPCs, whereas states should be more general and useful to more than one type of NPC.

HeroEngine provides a few sample AI states: _AiStateSampleFixedPath, _AiStateSampleWander, and _AiStateSampleFixedWander. You use these as a basis for some of your own AI states, but they may be insufficient for actual game use. The FixedPath state will allow an agent to follow a path from start to end (or end to start if _followReverse is set true). Once it has traversed all waypoints in the path, the state pops itself off the stack. The Wander state is setup to never stop itself. It continuously chooses a point within the agent's territory and generates a path (via $PathPlanner) to get there. It then picks a new point, and so on. The FixedWander state inherits from the Wander state, but instead of picking points inside of a territory, it simply picks a point at some fixed distance from its origin.


Sample Agent Logic

The main method to implement to get an agent to do something is _AgentStateNeeded(). This method gets called when the agent's state stack is empty, so not long after it's been created and woken up it will end up calling this method. For our sample, we'll make an agent walk around on a path named "agentTest".

method _AgentStateNeeded()
  //  I don't have anything to do.  Let's try to wander on a path.
  var path = PathUtils:GetPathByName("agentTest")
  if path == None
    $CHAT.ChatArea("", me + " - could not find the $QagentTest$Q path")
    return
  .
  pathstate as NodeRef of Class _AiStateSampleFixedPath = CreateNodeFromClass("_AiStateSampleFixedPath")
  pathstate._followPath = path
  me._PushAgentState(pathstate, "Needed something to do, decided to follow the " + path.PathName + " path")
.

You should see the NPC go to the first waypoint, then go to each successive waypoint. When it reaches the end, the state will pop, causing _AgentStateNeeded() to run again. This means it will follow the path again, so it will return to the start of the path and traverse it again.

Keep in mind that this specific logic isn't particularly efficient. Because it's doing the same thing over and over, it would be better to use a state that knows how to reset itself rather than simply popping (and thus destroying) itself when it's done. You could create your own AI state class and play around with mimicking the sample behavior but eliminating the _PopAgentState call.

If you don't see anything happening:

Understanding State Logic

We'll step through a few of the sample AI states to see how it all works. All AI states should inherit from _AiState. _AiState is an abstract class which implements the base methods all states need. Like _AiAgent, you may wish to create your own game-specific abstract class which all of your states will inherit from. The sample class _AiStateSampleFixedPath inherits from _AiState and adds 3 of its own fields: _followPath (NodeRef of Class PathNode), _followNextWaypoint (Integer), and _followReverse (Boolean).


_AiStateSampleFixedPath

This sample AI is designed for a single simple task: to drive the agent along a waypoint path.

The first method is _EnterAiState(). When the state gets pushed onto the agent's stack (like in the example _AgentStateNeeded() method above) this method gets called. Here is where your state can initialize values, check fields that it expects to have set, and so on.

method _EnterAiState()
  //  called when the state is pushed on top of the stack
  assert(me._followPath != None, "state " + me + " does not have a valid path set")
  var agent = me._GetAiAgent()
  var listen = agent.createNodeListener(me, false)
  agent.addListener(listen)  
  if me._followReverse
    me._followNextWaypoint = me._followPath.PathPoints.length
  else
    me._followNextWaypoint = 1
  .
  HeadToNextWaypoint(me, agent, false)
.

The sample state expects _followPath to be properly set to a PathNode. (We do this in the example _AgentStateNeeded() method above.) We're going to use $CHARDRIVER to move the agent, which uses an event callback, so we have to setup a listener node to listen for that event later on. If _followReverse is set, then _followNextWaypoint is set to the last waypoint in the path, otherwise it is set to 1. At the end of the Enter method, HeadToNextWaypoint is called, which is a private function that handles figuring out the next waypoint on the path and then moving to it using $CHARDRIVER.

method _SuspendAiState()
  //  need to get rid of our listener so we don't keep responding to events
  var agent = me._GetAiAgent()
  var listeners = agent.listListeners()
  foreach listen in listeners
    where listen is kindof obsListenerNode
      if listen.ListenerNotifyNodeRefID == me
        listen.deleteListener()
      .
    .
  .
.

The suspend method is called if another state gets pushed onto the stack. States can push new states (though this one doesn't), agent AI can push new states, and of course a developer can manually push new states. So even if a state itself doesn't expect to push a new state on the stack, it should still properly handle the suspend and resume methods. The suspend method here gets rid of the listener we created so we don't respond to events from $CHARDRIVER that another state may have initiated.

method _ResumeAiState(previous as NodeRef of Class _AiState)
  //  Put the listener back on and head to the next waypoint.
  var agent = me._GetAiAgent()
  var listen = agent.createNodeListener(me, false)
  agent.addListener(listen)  
  HeadToNextWaypoint(me, me._GetAiAgent(), $DRIVER._DriverIsDriving(me._GetAiAgent()))
.

The accompanying resume state is called when the state returns to the top of the agent's state stack, because whatever state was pushed on top of it was popped off for whatever reason. The resume method creates another listener for the agent and calls HeadToNextWaypoint to get it moving again.

method _ExitAiState()
  //  called just before the state is popped off the stack
.

The exit state is called just before the state is popped off the stack as a final chance to cleanup any extraneous nodes, associations, etc. In this case we do nothing because the listener is smart enough to delete itself when the node that it was created for (the state) goes away.

method _ValidateAgent(agent as NodeRef) as Boolean
  return true
.

The _ValidateAgent method is where a state can ensure that the potential agent is valid for the particular state. You might have a state that expects the agent to be a human, for example, and not an orc or an elf. You do that verification step here. The validation method is always called when a state is going to be pushed onto an agent, and if it fails, a script error is generated. In the case of our sample state, just any agent that can move is valid, and since that's pretty general, we just assume any agent will work.

method _AiStateFromCommand(args as List of String, error references String) as Boolean
  if args.length < 1
    error = "No path specified."
    return false
  .
  var path = PathUtils:GetPathByName(args[1])
  if path == None
    error = "Invalid path $Q" + args[1] + "$Q specified."
    return false
  .
  me._followPath = path
  return true
.

The _AiStateFromCommand method is used by the /aicontrol command when you use the PUSH option to manually force a state onto an agent's stack. (You can find the relevant code in the _AiControlClassMethods server script.) Because some states require certain things to be setup prior to being pushed on to the agent's stack (this sample state requires _followPath to be set to a PathNode), you can use this method to parse the input from the user. Here we expect the name of a path and we verify that it exists and then set the _followPath field. Returning true indicates the state can be pushed onto the stack; returning false means it should not be pushed. The error string is automatically displayed to the user via chat upon failure.

The other parts of the state are the private function HeadToNextWaypoint, which drives the agent to the next waypoint or pops the state if the end of the path has been reached, and the shared function EventRaisedNotify, which is called by $CHARDRIVER when the agent reaches a waypoint. It then calls HeadToNextWaypoint to head to the next waypoint.

_AiStateSampleWander

Another sample AI state provided is the 'sample wander', which leverages the Pathfinding System to choose suitable paths to follow when navigating from an agent's current position to a random point within a bounded radius R.

method _EnterAiState()
  //  called when the state is pushed on top of the stack
  //  register for events and find a new point to wander toward
  var agent = me._GetAiAgent()
  agent.addListener( me )
 
  me._fixedWanderOrigin = agent.getposition()
  me._SampleWanderToNewPoint()
.


Like the fixed path state, the 'sample wander' state's _EnterAiState() method creates a listener which will respond to path completion events and adds it to the AI agent. As before, we also take this opportunity to choose a new point to travel to - in this case, a random point within a fixed radius from the agent. We then instruct $CHARDRIVER to drive our character to this new location using a path returned from the Pathfinding System.

method _SuspendAiState()
  var agent = me._GetAiAgent()
  var listeners = agent.listListeners()
  foreach listen in listeners
    where listen is kindof obsListenerNode
      if listen.ListenerNotifyNodeRefID == me
        listen.deleteListener()
      .
    .
  .
 
  $CHARDRIVER._DriverClearPoints(agent, true)
.

The suspend method in our sample wander state is exactly like that of our fixed path state. We remove our listener (to avoid having messages delivered to us while we are inactive) and clear pending chardriver pathing points to terminate our movement.

method _ResumeAiState(previous as NodeRef of Class _AiState)
  me._EnterAiState()
.

Our resume method performs the same actions as our _EnterAiState() did, adding an event listener to the agent and again choosing a new point to wander to.

method _ExitAiState()
  $CHARDRIVER._DriverClearPoints(me._GetAiAgent(), true)
.

The exit method, as before, is called just before our AI state is popped off of the stack; it will clear the driver's path points for our agent to cease its movement.

method _SampleWanderToNewPoint()
  var agent = me._GetAiAgent()
 
  clear me._SampleWanderPoints
 
  agent._AgentDebugMsg("Requesting a random point in a Sphere using starting position(" + me._fixedWanderOrigin + ")" )
 
  $PathSystem._PathSystemGetRandomPointInSphere( me, $PathSystem._pathSystem_GetWalkableParameterSetHandle(), me._fixedWanderOrigin, 3.0 )
.

The most notable difference between the fixed path state and our sample wander state is the method by which we determine our next pathing points. Our fixed path state used a predefined set of path waypoint nodes to determine pathing; our wander state, on the other hand, leverages the Pathfinding System to retrieve paths. As we can see from the last line of the _SampleWanderToNewPoint() method, we request a path from $PATHSYSTEM which will connect our current location to one some distance away.

method _PathSystem_PathComplete(path as NodeRef of Class _PathSystemResponse)
// Parameters:
//   path - class _PathSystemResponse containing the calculated path
  var agent = me._GetAiAgent()
  assert(agent != None, "Sample wander state node hanging around without an agent!")
  agent._AgentDebugMsg("Got list of points (" + path._psrPointList.length + ") back from $$PATHSYSTEM")
  me._SampleWanderPoints = path._psrPointList
 
  // If the initial point is less than 1 meter from where we are...drop it as we are close enough
  if me._SampleWanderPoints.length > 0 and vectorLength(me._SampleWanderPoints[1] - agent.getPosition()) < .1
    remove me._SampleWanderPoints at 1
  .
 
  if me._SampleWanderPoints.length < 2
    me._SampleWanderToNewPoint()
    return
  .
 
  $CHARDRIVER._DriverAddPoints(agent, me._SampleWanderPoints, WALK, true)
 
...
.

When the Pathfinding System returns a valid path for us to use, we perform some simple pre-processing on it before sending it off to $CHARDRIVER. Once delivered, the points are used to move us toward our destination.

Method EventRaised( obs as NodeRef of Class ObsSubject, data as NodeRef )
  where data is kindof eventObject
    when data.EventType
      is "_driverreacheddestination"
        var agent = me._GetAiAgent()
        agent._AgentDebugMsg("Reached my destination, requesting a new random point." )
        me._SampleWanderToNewPoint()
      .
    .
  .
.

Once we've reached our destination, we receive a callback indicating that we've completed our task. We then turn right around and request a new point to wander to via a call to _SampleWanderToNewPoint(). This closes the loop and sets us in a never-ending loop of aimless wandering.

See also

Personal tools
Namespaces
Variants
Actions
Navigation
Toolbox