"""
This file contains the implementation of the classes BaseState, StateMachine,
HierarchicalState, and StateStack. These classes are used to create a state
machine that can change between states.
Author: Alejandro Mujica (aledrums@gmail.com)
"""
from typing import TypeVar, Tuple, Dict, Any, Optional
import pygame
from .input_handler import InputData
[docs]
class BaseState:
"""
This class represents an empty state. Any state machines
will start in this state.
It also is the base for any state. You should extend
this class to implement any new state class.
"""
def __init__(self, state_machine: TypeVar("StateMachine")) -> None:
self.state_machine: TypeVar("StateMachine") = state_machine
[docs]
def enter(self, *args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
"""
Method to be executed when the state machine enters in the state.
"""
pass
[docs]
def exit(self) -> None:
"""
Method to be executed when the state machine exits from the state.
"""
pass
[docs]
def update(self, dt: float) -> None:
pass
[docs]
def render(self, surface: pygame.Surface) -> None:
pass
[docs]
class StateMachine:
"""
The state machine.
Usage:
States are instantiated when they are set to the attribute 'current'.
They are passed to the constructor as a dictionary argument containing
pairs either (state_name, StateClass) or (state_name, function_to_build_state).
It is expected that added states contain the methods: enter, exit,
update, and render. It is recommended creating states by inheriting
from BaseState in base_state module.
Example:
state_machine = StateMachine({
'start': StartState,
'play': lambda sm: return PlayState(sm)
})
state_machine.change('start')
"""
def __init__(self, states: Optional[Dict[str, BaseState]] = None) -> None:
"""
Set the state machine on its initial value.
:param states: Dictionary of states. Each pair in the dictionary
have to be the name of the state associated with the generator
of the state. This could be either the name of the state class
or a function that instantiates the state. That value should
receive the instance of the state machine when it is called.
"""
self.states: Dict[str, BaseState] = states if states is not None else {}
# The initial state is the empty state
self.current = BaseState(self)
[docs]
def change(
self, state_name: str, *args: Tuple[Any], **kwargs: Dict[str, Any]
) -> None:
"""
Change the state of the machine.
:param state_name: The name of the state that will be set.
*args and **kwargs: Any argument list of keyword arguments that are accepted by the enter method of the new state.
:raises KeyError: If the arg state_name is not as a key in the states dictionary.
"""
self.current.exit()
self.current = self.states[state_name](self)
self.current.enter(*args, **kwargs)
[docs]
def update(self, dt: float) -> None:
"""
Call to update of the current state of the machine.
:param dt: Time elapsed of the game loop.
"""
self.current.update(dt)
[docs]
def render(self, surface: pygame.Surface) -> None:
"""
Call to render of the current state of the machine.
:param surface: The surface where the state should be rendered on.
"""
self.current.render(surface)
[docs]
class HierarchicalState(BaseState):
"""
A state that owns a nested StateMachine (its "substate machine").
A HierarchicalState is a regular state from the point of view of the
StateMachine (or StateStack) that owns it -- it can be added to a
'states' dictionary and driven through enter/exit/on_input/update/render
just like any other BaseState -- but it also behaves as the root of a
sub-machine: entering it can select a default substate, and its
on_input/update/render delegate down to whichever substate is currently
active in that sub-machine.
This is enough to build a hierarchical state machine (HFSM): a
"superstate" is simply a HierarchicalState whose substates are BaseState
(or, recursively, HierarchicalState) instances. Nesting composes for
free: a substate that is itself a HierarchicalState will delegate down
to its own substate machine in turn, so arbitrarily deep hierarchies
require no extra machinery.
Usage:
Subclasses that override enter, on_input, update, or render and still
want the substate machine to be driven MUST call the corresponding
super().foo(...) method (typically first). Subclasses that do not
override these methods inherit the delegation directly, so they nest
correctly with no extra code.
Example:
class Walking(BaseState):
def update(self, dt: float) -> None:
... # move towards the next waypoint
class LookingAround(BaseState):
def update(self, dt: float) -> None:
... # rotate in place scanning the surroundings
class Patrol(HierarchicalState):
def __init__(self, state_machine: StateMachine) -> None:
super().__init__(
state_machine,
substates={"walking": Walking, "looking_around": LookingAround},
initial_substate="walking",
)
def update(self, dt: float) -> None:
super().update(dt)
if self.reached_waypoint():
self.change_substate("looking_around")
guard_fsm = StateMachine({"patrol": Patrol, "alert": Alert})
guard_fsm.change("patrol")
"""
def __init__(
self,
state_machine: TypeVar("StateMachine"),
substates: Optional[Dict[str, BaseState]] = None,
initial_substate: Optional[str] = None,
) -> None:
"""
:param state_machine: The state machine that owns this state.
:param substates: Dictionary of substates, with the same shape
accepted by StateMachine's constructor (name -> state class or
factory function).
:param initial_substate: Name of the substate to enter automatically
when this state's enter method runs. If None, no substate is
selected automatically and the substate machine stays in its empty
BaseState until change_substate is called explicitly.
"""
super().__init__(state_machine)
self.substate_machine: StateMachine = StateMachine(substates)
self.initial_substate: Optional[str] = initial_substate
[docs]
def enter(self, *args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
"""
Select the initial substate, if one was configured, forwarding any
received arguments to its enter method.
:param args and kwargs: Any argument list or keyword arguments
accepted by the initial substate's enter method.
"""
if self.initial_substate is not None:
self.substate_machine.change(self.initial_substate, *args, **kwargs)
[docs]
def change_substate(
self, state_name: str, *args: Tuple[Any], **kwargs: Dict[str, Any]
) -> None:
"""
Change the state of the nested substate machine.
:param state_name: The name of the substate that will be set.
:param args and kwargs: Any argument list or keyword arguments
accepted by the enter method of the new substate.
:raises KeyError: If state_name is not a key in the substates
dictionary.
"""
self.substate_machine.change(state_name, *args, **kwargs)
[docs]
def update(self, dt: float) -> None:
"""
Delegate update to the current substate of the substate machine.
:param dt: Time elapsed of the game loop.
"""
self.substate_machine.update(dt)
[docs]
def render(self, surface: pygame.Surface) -> None:
"""
Delegate render to the current substate of the substate machine.
:param surface: The surface where the state should be rendered on.
"""
self.substate_machine.render(surface)
[docs]
class StateStack:
"""
Class to stack states. It renders all of then but it only updates the top state.
Usage:
The stack is created empty. States are pushed to the stack and popped from it.
state_stack = StateStack()
state_stack.push(state1)
state_stack.push(state2)
state_stack.pop()
"""
def __init__(self) -> None:
"""
Creates an empty stack.
"""
self.states = []
[docs]
def update(self, dt: float) -> None:
"""
Call to update of the top state of the stack.
:param dt: Time elapsed of the game loop.
:raises RuntimeError: If the stack is empty.
"""
if len(self.states) == 0:
raise RuntimeError("State stacks is empty")
self.states[-1].update(dt)
[docs]
def render(self, surface: pygame.Surface) -> None:
"""
Call to render all of the states in the stack.
:param surface: The surface where the state should be rendered on.
"""
for state in self.states:
state.render(surface)
[docs]
def clear(self) -> None:
"""
Clear the stack.
"""
self.states = []
[docs]
def push(
self, state: BaseState, *args: Tuple[Any], **kwargs: Dict[str, Any]
) -> None:
"""
Add a new state on the top of the stack.
:param state: The state to be added based on BaseState.
:*args and **kwargs: Any argument list of keyword arguments that are accepted by the enter method of the new state.
"""
self.states.append(state)
state.enter(*args, **kwargs)
[docs]
def pop(self) -> None:
"""
Remove the top state of the stack.
:raises RuntimeError: If the stack is empty.
"""
if len(self.states) == 0:
raise RuntimeError("State stacks is empty")
self.states[-1].exit()
self.states.pop()