Finite State Machine base class
While all that is needed for a Finite State Machine is a simple integer variable, it is often clearer in the code to refer to the states by name. This base class (it is intended to be used as a base for other specific state machine classes) allows an optional sequence of state names to be supplied.
To allow for new states to be added in future releases only the
name of the renpy.store
attribute is kept by the
state machine, not the sequence itself.
This ensures earlier versions of the state name sequence are not
persisted in the save file.
Constructor
The constructor is used to initialise the base Finite State Machine and create two members:
-
_stateNumM
is the state number that starts at zero -
_stateNamesM
is the store name of an optional sequence of state names
# # Finite State Machine. # init python: class Fsm(object): """ Basic state machine. State is held as a non-negative integer (0..n-1). An instance can optionally be supplied with a sequence of names for each state. If a sequence is provided the state is limited to only those states. Declaration: define stateNames = ('init', 'first', 'second',) default state = Fsm('stateNames') """ # --------------------------------------------------------------------- # Constructor # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def __init__(self, stateNames=None): """ Construct a basic finite state machine with initial state 0. Optionally provide a renpy.store name of sequence of state names. This can be a list or a tuple of strings. :param stateNames: Optional store name of a sequence of state names """ self._stateNumM = 0 self._stateNamesM = stateNames
Accessors
A number of accessor methods are provided to access and change the state by number or by name.
Reset
The reset()
method simply sets the state machine back to
its initial state.
# --------------------------------------------------------------------- # Accessors # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def reset(self): """ Reset to state zero. """ self.state = 0
State
The state
is defined as a property so it can be accessed
as though it were a member, but sanity checking can be performed when
it is assigned to.
- The state cannot be set to
None
- The state cannot be set to a non-integer value
- The state cannot be set to a negative value
- If a sequence of state names has been given, it has to be a valid index into that sequence.
@property def state(self): """ The state number which is a non-negative integer. """ return self._stateNumM @state.setter def state(self, value): """ Set the state number. The state number cannot be None or negative. If a sequence of state names was provided the value must be a valid index. :param value: The new state number """ if value is None: raise ValueError("State cannot be set to None.") if not isinstance(value, int): raise ValueError("State cannot be set to a non-integer value ({})".format(value)) if value < 0: raise ValueError("State cannot be set to a negative value ({}).".format(value)) if self._stateNamesM is not None: names = self._lookupNames() if value >= len(names): raise ValueError("State cannot be set outside known states 0..{} ({}).".format(len(names)-1, value)) self._stateNumM = value
Optionally for debugging state transitions the following could be added
to the end of state.setter
assuming you have a dbg
character and default debugFsm
somewhere.
Alternatively do something similar with printf
to the
console.
if debugFsm: if names is not None: dbg("{}.state={}:{}".format(self.__class__.__name__, value, names[value])) else: dbg("{}.state={}".format(self.__class__.__name__, value))
State name
The stateName
is also defined as a property.
The getter retrieves the state name from the sequence of states
(if provided at construction).
The setter checks for a valid state name.
@property def stateName(self): """ The state name corresponding to the current state. """ names = self._lookupNames() if names is None: return str(self._stateNumM) return names[self._stateNumM] @stateName.setter def stateName(self, value): """ Set the state by name. Only valid if a sequence of state names was provided. :param value: The new state name """ names = self._lookupNames() if names is None: raise ValueError("State names cannot be used without first providing a list of states ({}).".format(value)) if value not in names: raise ValueError("State '{}' is not a valid state name.".format(value)) self.state = names.index(value)
State number
It may occasionally be useful to retrieve the state number corresponding to a state name.
classes/fsm.rpy (cont.)def stateNumber(self, value): """ Get the state number for a given state name. :param value: A state name :return: The state number """ names = self._lookupNames() if names is None: raise ValueError("State names cannot be used without first providing a list of states ({}).".format(value)) if value not in names: raise ValueError("State '{}' is not a valid state name.".format(value)) return names.index(value)
Special methods
Two of the special (dunder) methods are given implementations. Others could be added if needed. I chose not to add the various comparison operations as comparing the state of two finite state machines does not seem to be useful.
bool()
Boolean conversion is handled by the special method __bool__
in Python 3. In Python 2 this was __non_zero__
.
For the state machine it can be useful if the reset state should
convert to False
and all other states read as
True
.
# --------------------------------------------------------------------- # Special methods # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def __bool__(self): """ Determine if the state has advanced beyond the reset state 0. :return: True if not in the initial state """ return self._stateNumM != 0 __non_zero__ = __bool__ # Python 2 compatibility
str()
String conversion is handled by the special method __str__
.
If a sequence of state names is available use the state number followed
by a :
and the state name. Otherwise just use the number.
def __str__(self): """ Generate a printable string describing the state. :return: A printable state name or number """ names = self._lookupNames() if names is None: return str(self._stateNumM) return "{}:{}".format(self._stateNumM, names[self._stateNumM])
Private methods
The only "private" method needed is the one to retrieve the sequence of
state names.
This is designed to fail gracefully by returning None
if
no store name for the sequence was provided at construction or if the
attribute name is not valid.
# --------------------------------------------------------------------- # Private methods # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _lookupNames(self): """ Retrieve the sequence of state names from the renpy store. :return: The state name sequence, or None if not provided or available """ if self._stateNamesM is None: return None return getattr(renpy.store, self._stateNamesM, None)
Example use
This is really intended to be a base for a derived class that adds logic for transitions, but it can be used as it stands. First, create a state machine with three named states and print out its initial state number and name:
define stateNames = ('init', 'first', 'second') default fsm = Fsm('stateNames') label exFsm: "Initial state: [fsm.state] [fsm.stateName]"
Initial state: 0 init
The state can be changed by assigning to state
:
$ fsm.state = 1 "New state: [fsm.state] [fsm.stateName]"
New state: 1 first
It can also be changed with operators like +=
:
$ fsm.state += 1 "New state: [fsm.state] [fsm.stateName]"
New state: 2 second
And reset if needed.
$ fsm.reset() "Reset state: [fsm.state] [fsm.stateName]"
Reset state: 0 init
So far it behaves a lot like a simple integer, but
the state can also be changed by assigning one of the defined
state names to stateName
:
$ fsm.stateName = 'second' "New state: [fsm.state] [fsm.stateName]"
New state: 2 second
These operations would all cause exceptions though as the code is checking to make sure only valid states are allowed:
# These would all raise exceptions: $ fsm.stateName = 'third' $ fsm.state = 3 $ fsm.state = 'init' $ fsm.state = None $ fsm.state = 0.1