Part 3 - UI State#

Note

This tutorial is still a work-in-progress. The resources being used are tracked here. Feel free to discuss this tutorial or share your progress on the Github Discussions forum.

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.

State protocol#

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 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 __slots__ = () [1] in case the class is used for a subclass.

game/state.py should look like this:

"""Base classes for states."""
from __future__ import annotations

from typing import Protocol

import tcod.console
import tcod.event


class State(Protocol):
    """An abstract game state."""

    __slots__ = ()

    def on_event(self, event: tcod.event.Event) -> None:
        """Called on events."""

    def on_draw(self, console: tcod.console.Console) -> None:
        """Called when the state is being drawn."""

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.

State globals#

A new global will be added: 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, menus, and other “history aware” states.

State functions#

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 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 g.states[-1].on_draw(g.console). If g.states is empty then this function should immediately return instead of doing anything. Empty containers in Python are False when checked for truthiness.

Next the function def main_loop() -> None: is created. The while loop from main will be moved to this function. The while loop will be replaced by while g.states: so that this function will exit if no state exists. Drawing will be replaced by a call to main_draw. Events in the for-loop will be passed to the active state g.states[-1].on_event(event). Any states on_event method could potentially change the state so g.states must be checked to be non-empty for every handled event.

"""State handling functions."""
from __future__ import annotations

import tcod.console

import g


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 main_loop() -> None:
    """Run the active state forever."""
    while g.states:
        main_draw()
        for event in tcod.event.wait():
            if g.states:
                g.states[-1].on_event(event)

Now main.py can be edited to use the global variables and the new game loop.

Add import g and import game.state_tools. Replace references to console with g.console. Replace references to context with g.context.

States are initialed by assigning a list with the initial state to g.states. The previous game loop is replaced by a call to game.state_tools.main_loop().

...

import g
import game.state_tools

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 = [ExampleState(player_x=console.width // 2, player_y=console.height // 2)]
    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.

Footnotes