Panda3D Manual: FSM with input

Another common use for FSM's is to provide an abstraction for AI state. For this purpose, you would like to supply an "input" string to the FSM and let the FSM decide which state it should transition to rather than explicitly specifying the target state name.

Consider the following FSM state diagram:

straight straight
North left East
left left
West left South
straight straight

Here the text next to an arrow represents the "input" string given to the FSM, and the direction of the arrow represents the state transition that should be made for that particular input string, from the indicated starting state.

In this example, we have encoded a simple FSM that determines which compass direction a character will be facing after either turning left or continuing straight. The input will be either "left" or "straight", and the result is a transition to a new state that represents the new compass direction, based on the previous compass direction. If we request "left" from state North, the FSM transitions to state West. On the other hand, if we request "left" from state South, the FSM transitions to state East. If we request "straight" from any state, the FSM should remain in its current state.

To implement this in Panda3D, we define a number of filter functions, one for each state. The purpose of this function is to decide what state to transition to next, if any, on receipt of a particular input.

A filter function is created by defining a python method named filterStateName(), where StateName is the name of the FSM state to which this filter function applies. The filterStateName method receives two parameters, a string and a tuple of arguments (the arguments contain the optional additional arguments that might have been passed to the fsm.request() call; it's usually an empty tuple). The filter function should return the name of the state to transition to. If the transition should be disallowed, the filter function can either return None to quietly ignore it, or it can raise an exception. For example:

class CompassDir(FSM):
 
    def filterNorth(self, request, args):
        if request == 'straight':
            return 'North'
        elif request == 'left':
            return 'West'
        else:
            return None
 
    def filterWest(self, request, args):
        if request == 'straight':
            return 'West'
        elif request == 'left':
            return 'South'
        else:
            return None
 
    def filterSouth(self, request, args):
        if request == 'straight':
            return 'South'
        elif request == 'left':
            return 'East'
        else:
            return None
 
    def filterEast(self, request, args):
        if request == 'straight':
            return 'East'
        elif request == 'left':
            return 'North'
        else:
            return None

Note that input strings, by convention, should begin with a lowercase letter, as opposed to state names, which should begin with an uppercase letter. This allows you to make the distinction between requesting a state directly, and feeding a particular input string to an FSM. To feed input to this FSM, you would use the request() call, just as before:

myfsm.request('left') # or myfsm.request_left()
myfsm.request('left')
myfsm.request('straight') # or myfsm.request_straight()
myfsm.request('left')

If the FSM had been in state North originally, after the above sequence of operations it would now be in state East.

The defaultFilter method

Although defining a series of individual filter methods gives you the most flexibility, for many FSM's you may not need this much explicit control. For these cases, you can simply define a defaultFilter method that does everything you need. If a particular filterStateName() method does not exist, then the FSM will call the method named defaultFilter() instead; you can put any logic here that handles the general case.

For instance, we could have defined the above FSM using just the defaultFilter method, and a lookup table:

class CompassDir(FSM):
    nextState = {
        ('North', 'straight') : 'North',
        ('North', 'left') : 'West',
        ('West', 'straight') : 'West',
        ('West', 'left') : 'South',
        ('South', 'straight') : 'South',
        ('South', 'left') : 'East',
        ('East', 'straight') : 'East',
        ('East', 'left') : 'North',
        }
 
    def defaultFilter(self, request, args):
        key = (self.state, request)
        return self.nextState.get(key)

The base FSM class defines a defaultFilter() method that implements the default FSM transition rules (that is, allow all direct-to-state (uppercase) transition requests unless self.defaultTransitions is defined; in either case, quietly ignore input (lowercase) requests).

In practice, you can mix-and-match the use of the defaultFilter method and your own custom methods. The defaultFilter method will be called only if a particular state's custom filter method does not exist. If a particular state's filterStateName method is defined, that method will be called upon a new request; it can do any custom logic you require (and it can call up to the defaultFilter method if you like).