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.
Now add a few small classes using @attrs.define():
A Push class with a state: State attribute.
A Pop class with no attributes.
A Reset class with a state: State attribute.
Then add a 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.
New 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, sub-menus, and other prompts.
The console variable from main.py will be moved to g.py.
Add 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."""
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 is to handle the StateResult type.
Start by adding the def apply_state_result(result: StateResult) -> None: function.
This function will match result: to decide on what to do.
case Push(state=state): should append state to g.states.
case Pop(): should simply call g.states.pop().
case Reset(state=state): should call apply_state_result(Pop()) until g.state is empty then call apply_state_result(Push(state)).
case None: should be handled by explicitly ignoring it.
case _: handles anything else and should invoke raise TypeError(result) since no other types are expected.
Now 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 with mouse coordinates should be converted to tiles using tile_event = g.context.convert_event(event) before being passed to a state.
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 def get_previous_state(state: State) -> State | None:.
Get the index of state in g.states by identity [2] using current_index = next(index for index, value in enumerate(g.states) if value is state).
Return the previous state if current_index > 0 or else return None using return g.states[current_index - 1] if current_index > 0 else None.
Next is 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 None.
Then call the previous states State.on_draw method as normal.
Afterwards test 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 console.rgb["fg"] //= 4 and 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
Update states¶
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
...
Update main.py¶
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.
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
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