Skip to content

Commit f1c6e50

Browse files
committed
Refactor part 2 and split state changes into part 3
1 parent 336aa0a commit f1c6e50

File tree

2 files changed

+211
-160
lines changed

2 files changed

+211
-160
lines changed

docs/tutorial/part-02.rst

Lines changed: 66 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Part 2 - Entities
55

66
.. include:: notice.rst
77

8-
In part 2 entities will be added and the state system will be refactored to be more generic.
8+
In part 2 entities will be added and a new state will be created to handle them.
99
This part will also begin to split logic into multiple Python modules using a namespace called ``game``.
1010

1111
Entities will be handled with an ECS implementation, in this case: `tcod-ecs`_.
@@ -24,46 +24,6 @@ Create a new folder called ``game`` and inside the folder create a new python fi
2424
2525
This package will be used to organize new modules.
2626

27-
State protocol
28-
==============================================================================
29-
30-
To have more states than ``ExampleState`` one must use an abstract type which can be used to refer to any state.
31-
In this case a `Protocol`_ will be used, called ``State``.
32-
33-
Create a new module: ``game/state.py``.
34-
In this module add the class :python:`class State(Protocol):`.
35-
``Protocol`` is from Python's ``typing`` module.
36-
``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.
37-
These methods refer to types from ``tcod`` and those types will need to be imported.
38-
``State`` should also have :python:`__slots__ = ()` [#slots]_ in case the class is used for a subclass.
39-
40-
``game/state.py`` should look like this:
41-
42-
.. code-block:: python
43-
44-
"""Base classes for states."""
45-
from __future__ import annotations
46-
47-
from typing import Protocol
48-
49-
import tcod.console
50-
import tcod.event
51-
52-
53-
class State(Protocol):
54-
"""An abstract game state."""
55-
56-
__slots__ = ()
57-
58-
def on_event(self, event: tcod.event.Event) -> None:
59-
"""Called on events."""
60-
61-
def on_draw(self, console: tcod.console.Console) -> None:
62-
"""Called when the state is being drawn."""
63-
64-
The ``ExampleState`` class does not need to be updated since it is already a structural subtype of ``State``.
65-
Note that subclasses of ``State`` will never be in same module as ``State``, this will be the same for all abstract classes.
66-
6727
Organizing globals
6828
==============================================================================
6929

@@ -73,14 +33,15 @@ Any global variables which might be assigned from other modules will need to a t
7333
Create a new module: ``g.py`` [#g]_.
7434
This module is exceptional and will be placed at the top-level instead of in the ``game`` folder.
7535

76-
``console`` and ``context`` from ``main.py`` will now be annotated in ``g.py``.
77-
These will not be assigned here, only annotated with a type-hint.
36+
In ``g.py`` import ``tcod.context`` and ``tcod.ecs``.
37+
38+
``context`` from ``main.py`` will now be annotated in ``g.py`` by adding the line :python:`context: tcod.context.Context` by itself.
39+
Notice that is this only a type-hinted name and nothing is assigned to it.
40+
This means that type-checking will assume the variable always exists but using it before it is assigned will crash at run-time.
7841

79-
A new global will be added: :python:`states: list[game.state.State] = []`.
80-
States are implemented as a list/stack to support `pushdown automata <https://gameprogrammingpatterns.com/state.html#pushdown-automata>`_.
81-
Representing states as a stack makes it easier to implement popup windows, menus, and other "history aware" states.
42+
``main.py`` should add :python:`import g` and replace the variables named ``context`` with ``g.context``.
8243

83-
Finally :python:`world: tcod.ecs.Registry` will be added to hold the ECS scope.
44+
Then add the :python:`world: tcod.ecs.Registry` global to hold the ECS scope.
8445

8546
It is important to document all variables placed in this module with docstrings.
8647

@@ -89,107 +50,17 @@ It is important to document all variables placed in this module with docstrings.
8950
"""This module stores globally mutable variables used by this program."""
9051
from __future__ import annotations
9152
92-
import tcod.console
9353
import tcod.context
9454
import tcod.ecs
9555
96-
import game.state
97-
98-
console: tcod.console.Console
99-
"""The main console."""
100-
10156
context: tcod.context.Context
10257
"""The window managed by tcod."""
10358
104-
states: list[game.state.State] = []
105-
"""A stack of states with the last item being the active state."""
106-
10759
world: tcod.ecs.Registry
10860
"""The active ECS registry and current session."""
10961
110-
Now other modules can :python:`import g` to access global variables.
111-
11262
Ideally you should not overuse this module for too many things.
113-
When a variables can either be taken as a function parameter or accessed as a global then passing as a parameter is always preferable.
114-
115-
State functions
116-
==============================================================================
117-
118-
Create a new module: ``game/state_tools.py``.
119-
This module will handle events and rendering of the global state.
120-
121-
In this module add the function :python:`def main_draw() -> None:`.
122-
This will hold the "clear, draw, present" logic from the ``main`` function which will be moved to this function.
123-
Render the active state with :python:`g.states[-1].on_draw(g.console)`.
124-
If ``g.states`` is empty then this function should immediately :python:`return` instead of doing anything.
125-
Empty containers in Python are :python:`False` when checked for truthiness.
126-
127-
Next the function :python:`def main_loop() -> None:` is created.
128-
The :python:`while` loop from ``main`` will be moved to this function.
129-
The while loop will be replaced by :python:`while g.states:` so that this function will exit if no state exists.
130-
Drawing will be replaced by a call to ``main_draw``.
131-
Events in the for-loop will be passed to the active state :python:`g.states[-1].on_event(event)`.
132-
Any states ``on_event`` method could potentially change the state so ``g.states`` must be checked to be non-empty for every handled event.
133-
134-
.. code-block:: python
135-
136-
"""State handling functions."""
137-
from __future__ import annotations
138-
139-
import tcod.console
140-
141-
import g
142-
143-
144-
def main_draw() -> None:
145-
"""Render and present the active state."""
146-
if not g.states:
147-
return
148-
g.console.clear()
149-
g.states[-1].on_draw(g.console)
150-
g.context.present(g.console)
151-
152-
153-
def main_loop() -> None:
154-
"""Run the active state forever."""
155-
while g.states:
156-
main_draw()
157-
for event in tcod.event.wait():
158-
if g.states:
159-
g.states[-1].on_event(event)
160-
161-
Now ``main.py`` can be edited to use the global variables and the new game loop.
162-
163-
Add :python:`import g` and :python:`import game.state_tools`.
164-
Replace references to ``console`` with ``g.console``.
165-
Replace references to ``context`` with ``g.context``.
166-
167-
States are initialed by assigning a list with the initial state to ``g.states``.
168-
The previous game loop is replaced by a call to :python:`game.state_tools.main_loop()`.
169-
170-
.. code-block:: python
171-
:emphasize-lines: 3-4,12-15
172-
173-
...
174-
175-
import g
176-
import game.state_tools
177-
178-
def main() -> None:
179-
"""Entry point function."""
180-
tileset = tcod.tileset.load_tilesheet(
181-
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
182-
)
183-
tcod.tileset.procedural_block_elements(tileset=tileset)
184-
g.console = tcod.console.Console(80, 50)
185-
g.states = [ExampleState(player_x=console.width // 2, player_y=console.height // 2)]
186-
with tcod.context.new(console=g.console, tileset=tileset) as g.context:
187-
game.state_tools.main_loop()
188-
...
189-
190-
After this you can test the game.
191-
There should be no visible differences from before.
192-
63+
When a variable can either be taken as a function parameter or accessed as a global then passing as a parameter is always preferable.
19364

19465
ECS tags
19566
==============================================================================
@@ -435,7 +306,7 @@ Make sure :python:`return` has the correct indentation and is not part of the fo
435306
436307
return world
437308
438-
New in-game state
309+
New InGame state
439310
==============================================================================
440311

441312
Now there is a new ECS world but the example state does not know how to render it.
@@ -485,7 +356,6 @@ Then add the following:
485356
486357
Create a new :python:`class InGame:` decorated with :python:`@attrs.define(eq=False)`.
487358
States will always use ``g.world`` to access the ECS registry.
488-
States prefer ``console`` as a parameter over the global ``g.console`` so always use ``console`` when it exists.
489359

490360
.. code-block:: python
491361
@@ -494,8 +364,8 @@ States prefer ``console`` as a parameter over the global ``g.console`` so always
494364
"""Primary in-game state."""
495365
...
496366
497-
Create an ``on_event`` method matching the ``State`` protocol.
498-
Copying these methods from ``State`` or ``ExampleState`` should be enough.
367+
Create an ``on_event`` and ``on_draw`` method matching the ``ExampleState`` class.
368+
Copying ``ExampleState`` and modifying it should be enough since this wil replace ``ExampleState``.
499369

500370
Now to do an tcod-ecs query to fetch the player entity.
501371
In tcod-ecs queries most often start with :python:`g.world.Q.all_of(components=[], tags=[])`.
@@ -520,9 +390,13 @@ The query to see if the player has stepped on gold is to check for whichever ent
520390
The query for this is :python:`g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):`.
521391

522392
We will iterate over whatever matches this query using a :python:`for gold in ...:` loop.
523-
Add the entities ``Gold`` component to the player.
393+
Add the entities ``Gold`` component to the players similar component.
524394
Keep in mind that ``Gold`` is treated like an ``int`` so its usage is predictable.
525-
Now print the current amount of gold using :python:`print(f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g")`.
395+
396+
Format the added and total of gold using a Python f-string_: :python:`text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"`.
397+
Store ``text`` globally in the ECS registry with :python:`g.world[None].components[("Text", str)] = text`.
398+
This is done as two lines to avoid creating a line with an excessive length.
399+
526400
Then use :python:`gold.clear()` at the end to remove all components and tags from the gold entity which will effectively delete it.
527401

528402
.. code-block:: python
@@ -539,7 +413,8 @@ Then use :python:`gold.clear()` at the end to remove all components and tags fro
539413
# Auto pickup gold
540414
for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):
541415
player.components[Gold] += gold.components[Gold]
542-
print(f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g")
416+
text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"
417+
g.world[None].components[str] = text
543418
gold.clear()
544419
...
545420
@@ -556,6 +431,17 @@ Draw the graphic by assigning it to the consoles Numpy array directly with :pyth
556431
``console.rgb`` is a ``ch,fg,bg`` array and :python:`[["ch", "fg"]]` narrows it down to only ``ch,fg``.
557432
The array is in C row-major memory order so you access it with yx (or ij) ordering.
558433

434+
That ends the entity rendering loop.
435+
Next is to print the ``("Text", str)`` component if it exists.
436+
A normal access will raise ``KeyError`` if the component is accessed before being assigned.
437+
This case will be handled by the ``.get`` method of the ``Entity.components`` attribute.
438+
:python:`g.world[None].components.get(("Text", str))` will return :python:`None` instead of raising ``KeyError``.
439+
Assigning this result to ``text`` and then checking :python:`if text:` will ensure that ``text`` within the branch is not None and that the string is not empty.
440+
We will not use ``text`` outside of the branch, so an assignment expression can be used here to check and assign the name at the same time with :python:`if text := g.world[None].components.get(("Text", str)):`.
441+
442+
In this branch you will print ``text`` to the bottom of the console with a white foreground and black background.
443+
The call to do this is :python:`console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))`.
444+
559445
.. code-block:: python
560446
561447
...
@@ -568,6 +454,12 @@ The array is in C row-major memory order so you access it with yx (or ij) orderi
568454
graphic = entity.components[Graphic]
569455
console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg
570456
457+
if text := g.world[None].components.get(("Text", str)):
458+
console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))
459+
460+
Verify the indentation of the ``if`` branch is correct.
461+
It should be at the same level as the ``for`` loop and not inside of it.
462+
571463
``game/states.py`` should now look like this:
572464

573465
.. code-block:: python
@@ -633,7 +525,8 @@ The array is in C row-major memory order so you access it with yx (or ij) orderi
633525
# Auto pickup gold
634526
for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):
635527
player.components[Gold] += gold.components[Gold]
636-
print(f"Picked up ${gold.components[Gold]}, total: ${player.components[Gold]}")
528+
text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"
529+
g.world[None].components[("Text", str)] = text
637530
gold.clear()
638531
639532
def on_draw(self, console: tcod.console.Console) -> None:
@@ -645,27 +538,37 @@ The array is in C row-major memory order so you access it with yx (or ij) orderi
645538
graphic = entity.components[Graphic]
646539
console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg
647540
541+
if text := g.world[None].components.get(("Text", str)):
542+
console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))
543+
544+
Main script update
545+
==============================================================================
546+
648547
Back to ``main.py``.
649-
At this point you should know which imports to add and which are no longed needed.
650-
``ExampleState`` should be removed.
651-
``g.state`` will be initialized with :python:`[game.states.InGame()]` instead.
652-
Add :python:`g.world = game.world_tools.new_world()`.
548+
At this point you should know to import the modules needed.
549+
550+
The ``ExampleState`` class is obsolete and will be removed.
551+
``state`` will be created with :python:`game.states.InGame()` instead.
552+
553+
If you have not replaced ``context`` with ``g.context`` yet then do it now.
554+
555+
Add :python:`g.world = game.world_tools.new_world()` before the main loop.
653556

654557
``main.py`` will look like this:
655558

656559
.. code-block:: python
657-
:emphasize-lines: 5-12,22-23
560+
:emphasize-lines: 10-12,22-24,28
658561
659562
#!/usr/bin/env python3
660563
"""Main entry-point module. This script is used to start the program."""
661564
from __future__ import annotations
662565
663566
import tcod.console
664567
import tcod.context
568+
import tcod.event
665569
import tcod.tileset
666570
667571
import g
668-
import game.state_tools
669572
import game.states
670573
import game.world_tools
671574
@@ -676,11 +579,17 @@ Add :python:`g.world = game.world_tools.new_world()`.
676579
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
677580
)
678581
tcod.tileset.procedural_block_elements(tileset=tileset)
679-
g.console = tcod.console.Console(80, 50)
680-
g.states = [game.states.InGame()]
582+
console = tcod.console.Console(80, 50)
583+
state = game.states.InGame()
681584
g.world = game.world_tools.new_world()
682-
with tcod.context.new(console=g.console, tileset=tileset) as g.context:
683-
game.state_tools.main_loop()
585+
with tcod.context.new(console=console, tileset=tileset) as g.context:
586+
while True: # Main loop
587+
console.clear() # Clear the console before any drawing
588+
state.on_draw(console) # Draw the current state
589+
g.context.present(console) # Render the console to the window and show it
590+
for event in tcod.event.wait(): # Event loop, blocks until pending events exist
591+
print(event)
592+
state.on_event(event) # Dispatch events to the state
684593
685594
686595
if __name__ == "__main__":
@@ -692,10 +601,7 @@ You can review the part-2 source code `here <https://github.com/HexDecimal/pytho
692601

693602
.. rubric:: Footnotes
694603

695-
.. [#slots] This is done to prevent subclasses from requiring a ``__dict__`` attribute.
696-
If you are still wondering what ``__slots__`` is then `the Python docs have a detailed explanation <https://docs.python.org/3/reference/datamodel.html#slots>`_.
697-
698604
.. [#g] ``global``, ``globals``, and ``glob`` were already taken by keywords, built-ins, and the standard library.
699605
The alternatives are to either put this in the ``game`` namespace or to add an underscore such as ``globals_.py``.
700606
701-
.. _Protocol: https://mypy.readthedocs.io/en/stable/protocols.html
607+
.. _f-string: https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals

0 commit comments

Comments
 (0)