Warning
This part is still a draft and is being worked on. Sections here will be incorrect as these examples were hastily moved from an earlier part.
To have more states than ExampleState one must use an abstract type which can be used to refer to any state.
In this case a Protocol will be used, called State.
Create a new module: game/state.py.
In this module add the class :python:`class State(Protocol):`.
Protocol is from Python's typing module.
State should have the on_event and on_draw methods from ExampleState but these methods will be empty other than the docstrings describing what they are for.
These methods refer to types from tcod and those types will need to be imported.
State should also have :python:`__slots__ = ()` [1] in case the class is used for a subclass.
Now add a few small classes using :python:`@attrs.define()`:
A Push class with a :python:`state: State` attribute.
A Pop class with no attributes.
A Reset class with a :python:`state: State` attribute.
Then add a :python:`StateResult: TypeAlias = "Push | Pop | Reset | None"`. This is a type which combines all of the previous classes.
Edit State's on_event method to return StateResult.
game/state.py should look like this:
"""Base classes for states."""
from __future__ import annotations
from typing import Protocol, TypeAlias
import attrs
import tcod.console
import tcod.event
class State(Protocol):
"""An abstract game state."""
__slots__ = ()
def on_event(self, event: tcod.event.Event) -> StateResult:
"""Called on events."""
def on_draw(self, console: tcod.console.Console) -> None:
"""Called when the state is being drawn."""
@attrs.define()
class Push:
"""Push a new state on top of the stack."""
state: State
@attrs.define()
class Pop:
"""Remove the current state from the stack."""
@attrs.define()
class Reset:
"""Replace the entire stack with a new state."""
state: State
StateResult: TypeAlias = "Push | Pop | Reset | None"
"""Union of state results."""The InGame class does not need to be updated since it is already a structural subtype of State.
Note that subclasses of State will never be in same module as State, this will be the same for all abstract classes.
A new global will be added: :python:`states: list[game.state.State] = []`. States are implemented as a list/stack to support pushdown automata. Representing states as a stack makes it easier to implement popup windows, sub-menus, and other prompts.
The console variable from main.py will be moved to g.py.
Add :python:`console: tcod.console.Console` and replace all references to console in main.py with g.console.
"""This module stores globally mutable variables used by this program."""
from __future__ import annotations
import tcod.console
import tcod.context
import tcod.ecs
import game.state
context: tcod.context.Context
"""The window managed by tcod."""
world: tcod.ecs.Registry
"""The active ECS registry and current session."""
states: list[game.state.State] = []
"""A stack of states with the last item being the active state."""
console: tcod.console.Console
"""The current main console."""Create a new module: game/state_tools.py.
This module will handle events and rendering of the global state.
In this module add the function :python:`def main_draw() -> None:`.
This will hold the "clear, draw, present" logic from the main function which will be moved to this function.
Render the active state with :python:`g.states[-1].on_draw(g.console)`.
If g.states is empty then this function should immediately :python:`return` instead of doing anything.
Empty containers in Python are :python:`False` when checked for truthiness.
Next is to handle the StateResult type.
Start by adding the :python:`def apply_state_result(result: StateResult) -> None:` function.
This function will :python:`match result:` to decide on what to do.
:python:`case Push(state=state):` should append state to g.states.
:python:`case Pop():` should simply call :python:`g.states.pop()`.
:python:`case Reset(state=state):` should call :python:`apply_state_result(Pop())` until g.state is empty then call :python:`apply_state_result(Push(state))`.
:python:`case None:` should be handled by explicitly ignoring it.
:python:`case _:` handles anything else and should invoke :python:`raise TypeError(result)` since no other types are expected.
Now the function :python:`def main_loop() -> None:` is created.
The :python:`while` loop from main will be moved to this function.
The while loop will be replaced by :python:`while g.states:` so that this function will exit if no state exists.
Drawing will be replaced by a call to main_draw.
Events with mouse coordinates should be converted to tiles using :python:`tile_event = g.context.convert_event(event)` before being passed to a state.
:python:`apply_state_result(g.states[-1].on_event(tile_event))` will pass the event and handle the return result at the same time.
g.states must be checked to be non-empty inside the event handing for-loop because apply_state_result could cause g.states to become empty.
Next is the utility function :python:`def get_previous_state(state: State) -> State | None:`.
Get the index of state in g.states by identity [2] using :python:`current_index = next(index for index, value in enumerate(g.states) if value is state)`.
Return the previous state if :python:`current_index > 0` or else return None using :python:`return g.states[current_index - 1] if current_index > 0 else None`.
Next is :python:`def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None:`.
Call get_previous_state to get the previous state and return early if the result is :python:`None`.
Then call the previous states :python:`State.on_draw` method as normal.
Afterwards test :python:`dim and state is g.states[-1]` to see if the console should be dimmed.
If it should be dimmed then reduce the color values of the console with :python:`console.rgb["fg"] //= 4` and :python:`console.rgb["bg"] //= 4`.
This is used to indicate that any graphics behind the active state are non-interactable.
"""State handling functions."""
from __future__ import annotations
import tcod.console
import g
from game.state import Pop, Push, Reset, StateResult
def main_draw() -> None:
"""Render and present the active state."""
if not g.states:
return
g.console.clear()
g.states[-1].on_draw(g.console)
g.context.present(g.console)
def apply_state_result(result: StateResult) -> None:
"""Apply a StateResult to `g.states`."""
match result:
case Push(state=state):
g.states.append(state)
case Pop():
g.states.pop()
case Reset(state=state):
while g.states:
apply_state_result(Pop())
apply_state_result(Push(state))
case None:
pass
case _:
raise TypeError(result)
def main_loop() -> None:
"""Run the active state forever."""
while g.states:
main_draw()
for event in tcod.event.wait():
tile_event = g.context.convert_event(event)
if g.states:
apply_state_result(g.states[-1].on_event(tile_event))
def get_previous_state(state: State) -> State | None:
"""Return the state before `state` in the stack if it exists."""
current_index = next(index for index, value in enumerate(g.states) if value is state)
return g.states[current_index - 1] if current_index > 0 else None
def draw_previous_state(state: State, console: tcod.console.Console, dim: bool = True) -> None:
"""Draw previous states, optionally dimming all but the active state."""
prev_state = get_previous_state(state)
if prev_state is None:
return
prev_state.on_draw(console)
if dim and state is g.states[-1]:
console.rgb["fg"] //= 4
console.rgb["bg"] //= 4"""Menu UI classes."""
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
import attrs
import tcod.console
import tcod.event
from tcod.event import KeySym
import game.state_tools
from game.constants import DIRECTION_KEYS
from game.state import Pop, State, StateResult
class MenuItem(Protocol):
"""Menu item protocol."""
__slots__ = ()
def on_event(self, event: tcod.event.Event) -> StateResult:
"""Handle events passed to the menu item."""
def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None:
"""Draw is item at the given position."""
@attrs.define()
class SelectItem(MenuItem):
"""Clickable menu item."""
label: str
callback: Callable[[], StateResult]
def on_event(self, event: tcod.event.Event) -> StateResult:
"""Handle events selecting this item."""
match event:
case tcod.event.KeyDown(sym=sym) if sym in {KeySym.RETURN, KeySym.RETURN2, KeySym.KP_ENTER}:
return self.callback()
case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.LEFT):
return self.callback()
case _:
return None
def on_draw(self, console: tcod.console.Console, x: int, y: int, highlight: bool) -> None:
"""Render this items label."""
console.print(x, y, self.label, fg=(255, 255, 255), bg=(64, 64, 64) if highlight else (0, 0, 0))
@attrs.define()
class ListMenu(State):
"""Simple list menu state."""
items: tuple[MenuItem, ...]
selected: int | None = 0
x: int = 0
y: int = 0
def on_event(self, event: tcod.event.Event) -> StateResult:
"""Handle events for menus."""
match event:
case tcod.event.Quit():
raise SystemExit
case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:
dx, dy = DIRECTION_KEYS[sym]
if dx != 0 or dy == 0:
return self.activate_selected(event)
if self.selected is not None:
self.selected += dy
self.selected %= len(self.items)
else:
self.selected = 0 if dy == 1 else len(self.items) - 1
return None
case tcod.event.MouseMotion(position=(_, y)):
y -= self.y
self.selected = y if 0 <= y < len(self.items) else None
return None
case tcod.event.KeyDown(sym=KeySym.ESCAPE):
return self.on_cancel()
case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.RIGHT):
return self.on_cancel()
case _:
return self.activate_selected(event)
def activate_selected(self, event: tcod.event.Event) -> StateResult:
"""Call the selected menu items callback."""
if self.selected is not None:
return self.items[self.selected].on_event(event)
return None
def on_cancel(self) -> StateResult:
"""Handle escape or right click being pressed on menus."""
return Pop()
def on_draw(self, console: tcod.console.Console) -> None:
"""Render the menu."""
game.state_tools.draw_previous_state(self, console)
for i, item in enumerate(self.items):
item.on_draw(console, x=self.x, y=self.y + i, highlight=i == self.selected)class MainMenu(game.menus.ListMenu):
"""Main/escape menu."""
__slots__ = ()
def __init__(self) -> None:
"""Initialize the main menu."""
items = [
game.menus.SelectItem("New game", self.new_game),
game.menus.SelectItem("Quit", self.quit),
]
if hasattr(g, "world"):
items.insert(0, game.menus.SelectItem("Continue", self.continue_))
super().__init__(
items=tuple(items),
selected=0,
x=5,
y=5,
)
@staticmethod
def continue_() -> StateResult:
"""Return to the game."""
return Reset(InGame())
@staticmethod
def new_game() -> StateResult:
"""Begin a new game."""
g.world = game.world_tools.new_world()
return Reset(InGame())
@staticmethod
def quit() -> StateResult:
"""Close the program."""
raise SystemExit@attrs.define()
class InGame(State):
"""Primary in-game state."""
def on_event(self, event: tcod.event.Event) -> StateResult:
"""Handle events for the in-game state."""
(player,) = g.world.Q.all_of(tags=[IsPlayer])
match event:
case tcod.event.Quit():
raise SystemExit
case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:
player.components[Position] += DIRECTION_KEYS[sym]
# Auto pickup gold
for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):
player.components[Gold] += gold.components[Gold]
text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"
g.world[None].components[("Text", str)] = text
gold.clear()
return None
case tcod.event.KeyDown(sym=KeySym.ESCAPE):
return Push(MainMenu())
case _:
return None
...Now main.py can be edited to use the global variables and the new game loop.
Add :python:`import g` and :python:`import game.state_tools`.
Replace references to console with g.console.
States are initialed by assigning a list with the initial state to g.states.
The previous game loop is replaced by a call to :python:`game.state_tools.main_loop()`.
...
import g
import game.state_tools
import game.states
def main() -> None:
"""Entry point function."""
tileset = tcod.tileset.load_tilesheet(
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
)
tcod.tileset.procedural_block_elements(tileset=tileset)
g.console = tcod.console.Console(80, 50)
g.states = [game.states.MainMenu()]
with tcod.context.new(console=g.console, tileset=tileset) as g.context:
game.state_tools.main_loop()
...After this you can test the game. There should be no visible differences from before.
You can review the part-3 source code here.
Footnotes
| [1] | This is done to prevent subclasses from requiring a __dict__ attribute.
See :any:`slots` for a detailed explanation of what they are. |
| [2] | See :any:`is`.
Since State classes use attrs they might compare equal when they're not the same object.
This means :python:`list.index` won't work for this case. |