One of the things you do when writing a game is to write little state machines for objects that have non-trivial behaviors. A game loop runs frequently (dozens to hundreds of times a second) and iterates over all the state machines and advances each of them by one state. The state machines will appear to run in parallel with each other. However, there is no guarantee of what order the state machines are advanced, so care must be taken if a machine reads or modifies another machine’s state.
CLOS provides a particularly elegant way to code up a state
machine. The generic function step!
takes a state
machine and its current state as arguments. We represent the state
as a keyword. An eql
specialized method
for each state is written.
(defclass my-state-machine () ((state :initarg :initial-state :accessor state))) (defgeneric step! (state-machine state)) (defmethod step! ((machine my-state-machine) (state (eql :idle))) (when (key-pressed?) (setf (state machine) :keydown))) (defmethod step! ((machine my-state-machine) (state (eql :keydown))) (unless (key-pressed?) (setf (state machine) :idle)))
The state variables of the state machine would be held in other slots in the CLOS instance.
One advantage we find here is that we can write an :after
method on (setf state)
that is eql
specialized on the new state. For instance,
in a game the :after
method could start a new animation
for an object.
(defmethod (setf state) :after ((new-state (eql :idle)) (machine my-state-machine)) (begin-idle-animation! my-state-machine))
Now the code that does the state transition no longer has to worry about managing the animations as well. They’ll be taken care of when we assign the new state.
Because we’re using CLOS dispatch, the state can be a class
instance instead of a keyword. This allows us to create
parameterized states. For example, we could have
a delay-until
state that contained a timestamp.
The step!
method would compare the current time to the
timestamp and go to the next state only if the time has expired.
(defclass delay-until () ((timestamp :initarg :timestamp :reader timestamp))) (defmethod step! ((machine my-state-machine) (state delay-until)) (when (> (get-universal-time) (timestamp state)) (setf (state machine) :active)))
Variations
Each step!
method will typically have some sort of
conditional followed by an assignment of the state slot. Rather
that having our state methods work by side effect, we could make
them purely functional by having them return the next state of the
machine. The game loop would perform the assignment:
(defun game-loop (game) (loop (dolist (machine (all-state-machines game)) (setf (state machine) (step machine (state machine)))))) (defmethod step ((machine my-state-machine) (state (eql :idle))) (if (key-pressed?) :keydown :idle))
I suppose you could have state machines that inherit from other state machines and override some of the state transition methods from the superclass, but I would avoid writing such CLOS spaghetti. For any object you’ll usually want exactly one state transition method per state. With one state transition method per state, we could dispense with the keyword and use the state transition function itself to represent the state.
(defun game-loop (game) (loop (dolist (machine (all-state-machines game)) (setf (state machine) (funcall (state machine) machine))))) (defun my-machine/state-idle (machine) (if (key-pressed?) (progn (incf (kestroke-count machine)) #'my-machine/state-keydown) #'my-machine/state-idle)) (defun my-machine/state-keydown (machine) (if (key-pressed?) #'my-machine/state-keydown #'my-machine/state-idle))
The disadvantage of this doing it this way is that states are no longer keywords. They don’t print nicely or compare easily. An advantage of doing it this way is that we no longer have to do a CLOS generic function dispatch on each state transition. We directly call the state transition function.
The game-loop
function can be seen as a multiplexed
trampoline. It sits in a loop and calls what was returned from last
time around the loop. The state transition function, by returning
the next state transition function, is instructing the trampoline to
make the call. Essentially, each state transition function is tail
calling the next state via this trampoline.
State machines without side effects
The state transition function can be a pure function, but we can
remove the side effect in game-loop
as well.
(defun game-loop (machines states) (game-loop machines (map 'list #'funcall states machines)))
Now we have state machines and a driver loop that are pure functional.
No comments:
Post a Comment