Skip to content

Commit 258a233

Browse files
committed
Add tutorial part 1.
Initial draft of the tutorial. Not sure how to continue but I need this uploaded now.
1 parent 30aede9 commit 258a233

File tree

3 files changed

+264
-0
lines changed

3 files changed

+264
-0
lines changed

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"CURRENCYSUBUNIT",
111111
"CURRENCYUNIT",
112112
"currentmodule",
113+
"dataclasses",
113114
"datas",
114115
"DBLAMPERSAND",
115116
"DBLVERTICALBAR",
@@ -423,6 +424,7 @@
423424
"todos",
424425
"tolist",
425426
"touchpad",
427+
"traceback",
426428
"TRIGGERLEFT",
427429
"TRIGGERRIGHT",
428430
"tris",

docs/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ Contents:
2020
changelog
2121
faq
2222

23+
.. toctree::
24+
:maxdepth: 2
25+
:caption: Tutorial
26+
:glob:
27+
28+
tutorial/part-*
29+
2330
.. toctree::
2431
:maxdepth: 2
2532
:caption: python-tcod API

docs/tutorial/part-01.rst

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
Part 1
2+
==============================================================================
3+
4+
Initial script
5+
------------------------------------------------------------------------------
6+
7+
First start with a modern top-level script:
8+
9+
.. code-block:: python
10+
11+
#!/usr/bin/env python3
12+
"""This script is invoked to start the program."""
13+
from __future__ import annotations # PEP 563
14+
15+
16+
def main() -> None:
17+
"""Main entry point function."""
18+
pass # Nothing yet
19+
20+
21+
if __name__ == "__main__": # Top-level code environment
22+
main()
23+
24+
The first line is a `shebang <https://en.wikipedia.org/wiki/Shebang_%28Unix%29>`_ which allows direct execution of the script and will hint certain Python launchers which version to use.
25+
If you always invoke Python directly then you do not need this line.
26+
27+
The triple-quoted string is a `docstring <https://en.wikipedia.org/wiki/Docstring>`_.
28+
The one near the top documents the purpose for the module.
29+
The one in ``main`` documents that function.
30+
31+
``from __future__ import annotations`` tells Python to use `Postponed Evaluation of Annotations <https://peps.python.org/pep-0563/>`_.
32+
This is required for specific type-hints, such as a class using itself in its own annotations.
33+
This will also speed up the initialization of code which uses type-hints.
34+
35+
``def main() -> None:`` has no significance other than convention.
36+
Because this function returns nothing it is annotated with ``-> None``.
37+
38+
``if __name__ == "__main__":`` checks for the `Top-level code environment <https://docs.python.org/3/library/__main__.html>`_.
39+
This prevents tools from accidentally launching the script when they just want to import it.
40+
This is the only required boilerplate, everything else is optional.
41+
42+
Loading a tileset and opening a window
43+
------------------------------------------------------------------------------
44+
45+
From here it is time to setup a ``tcod`` program.
46+
Download `Alloy_curses_12x12.png <https://raw.githubusercontent.com/HexDecimal/python-tcod-tutorial-2023/6b69bf9b5531963a0e5f09f9d8fe72a4001d4881/data/Alloy_curses_12x12.png>`_ and place this file in your projects ``data/`` directory.
47+
This tileset is from the `Dwarf Fortress tileset repository <https://dwarffortresswiki.org/index.php/DF2014:Tileset_repository>`_ and you may choose to use any other tileset from there as long is you keep track of the filename yourself.
48+
49+
Load the tileset with :any:`tcod.tileset.load_tilesheet` and then pass it to :any:`tcod.context.new`.
50+
These functions are part of modules which have not been imported yet, so new imports need to be added.
51+
:any:`tcod.context.new` returns a :any:`Context` which is used with the ``with`` statement.
52+
53+
.. code-block:: python
54+
:emphasize-lines: 2,3,8-12
55+
56+
...
57+
import tcod.context # Add these imports
58+
import tcod.tileset
59+
60+
61+
def main() -> None:
62+
"""Load a tileset and open a window using it, this window will immediately close."""
63+
tileset = tcod.tileset.load_tilesheet(
64+
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
65+
)
66+
with tcod.context.new(tileset=tileset) as context:
67+
pass # The window will stay open for the duration of this block
68+
...
69+
70+
If an import fails that means you do not have ``tcod`` installed on the Python environment you just used to run the script.
71+
If you use an IDE then make sure the Python environment it is using is correct and then run ``pip install tcod`` from the shell terminal within that IDE.
72+
73+
If you run this script now then a window will open and then immediately close.
74+
If that happens without seeing a traceback in your terminal then the script is correct.
75+
76+
Configuring an event loop
77+
------------------------------------------------------------------------------
78+
79+
The next step is to keep the window open until the user closes it.
80+
81+
Since nothing is displayed yet a :any:`Console` should be created with ``"Hello World"`` printed to it.
82+
The size of the console can be used as a reference to create the context by adding the console to :any:`tcod.context.new`.
83+
84+
To actually display the console to the window the :any:`Context.present` method must be called with the console.
85+
Be sure to check the additional a parameters of :any:`Context.present`, you can keep aspect or enforce integer scaling.
86+
87+
Events are checked by iterating over all pending events.
88+
If your game is strictly turn-based then you should use :any:`tcod.event.wait`.
89+
If your game is real-time or has real-time animations then it should use :any:`tcod.event.get` instead.
90+
91+
Test if an event is for closing the window with ``isinstance(event, tcod.event.Quit)``.
92+
If this is True then you should exit the function, either with ``return``, or with :any:`sys.exit`, or with ``raise SystemExit``.
93+
94+
.. code-block:: python
95+
:emphasize-lines: 2,3,11-18
96+
97+
...
98+
import tcod.console
99+
import tcod.event
100+
101+
102+
def main() -> None:
103+
"""Show "Hello World" until the window is closed."""
104+
tileset = tcod.tileset.load_tilesheet(
105+
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
106+
)
107+
console = tcod.console.Console(80, 50)
108+
console.print(0, 0, "Hello World") # Test text by printing "Hello World" to the console
109+
with tcod.context.new(console=console, tileset=tileset) as context:
110+
while True: # Main loop
111+
context.present(console) # Render the console to the window and show it
112+
for event in tcod.event.wait(): # Event loop, blocks until pending events exist
113+
if isinstance(event, tcod.event.Quit):
114+
raise SystemExit()
115+
...
116+
117+
If you run this then you get a window saying ``"Hello World"``.
118+
The window can be resized and the console will be stretched to fit the new resolution.
119+
120+
An example game state
121+
------------------------------------------------------------------------------
122+
123+
What exists now is not very interactive.
124+
The next step is to change state based on user input.
125+
126+
Like ``tcod`` you'll need to install ``attrs`` with Pip, such as with ``pip install attrs``.
127+
Alternatively you can use :any:`dataclasses`, but this tutorial uses ``attrs`` since it has a more modern implementation.
128+
129+
Start by adding an ``attrs`` class called ``ExampleState``.
130+
This a normal class with the ``@attrs.define(eq=False)`` decorator added.
131+
132+
This class should hold coordinates for the player.
133+
It should also have a ``on_draw`` method which takes :any:`tcod.console.Console` as a parameter and marks the player position on it.
134+
The parameters for ``on_draw`` are ``self`` because this is an instance method and ``console: tcod.console.Console``.
135+
``on_draw`` returns nothing, so be sure to add ``-> None``.
136+
137+
:any:`Console.print` is the simplest way to draw the player because other options would require bounds-checking.
138+
139+
If ``tcod.console.Console`` is too verbose then you can add ``from tcod.console import Console`` so that you can use just ``Console`` instead.
140+
141+
.. code-block:: python
142+
143+
...
144+
import attrs
145+
146+
147+
@attrs.define(eq=False)
148+
class ExampleState:
149+
"""Example state with a hard-coded player position."""
150+
151+
player_x: int
152+
"""Player X position, left-most position is zero."""
153+
player_y: int
154+
"""Player Y position, top-most position is zero."""
155+
156+
def on_draw(self, console: tcod.console.Console) -> None:
157+
"""Draw the player glyph."""
158+
console.print(self.player_x, self.player_y, "@")
159+
...
160+
161+
Now remove the ``console.print(0, 0, "Hello World")`` line from ``main``.
162+
163+
Before the context is made create a new ``ExampleState`` with player coordinates on the screen.
164+
Each :any:`Console` has ``.width`` and ``.height`` attributes which you can divide by 2 to get a centered coordinate for the player.
165+
Use Python's floor division operator ``//`` so that the resulting type is ``int``.
166+
167+
Modify the drawing routine so that the console is cleared, then passed to ``ExampleState.on_draw``, then passed to :any:`Context.present`.
168+
169+
.. code-block:: python
170+
:emphasize-lines: 9,12-14
171+
172+
...
173+
def main() -> None:
174+
"""Run ExampleState."""
175+
tileset = tcod.tileset.load_tilesheet(
176+
"data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
177+
)
178+
tcod.tileset.procedural_block_elements(tileset=tileset)
179+
console = tcod.console.Console(80, 50)
180+
state = ExampleState(player_x=console.width // 2, player_y=console.height // 2)
181+
with tcod.context.new(console=console, tileset=tileset) as context:
182+
while True:
183+
console.clear() # Clear the console before any drawing
184+
state.on_draw(console) # Draw the current state
185+
context.present(console) # Display the console on the window
186+
for event in tcod.event.wait():
187+
if isinstance(event, tcod.event.Quit):
188+
raise SystemExit()
189+
...
190+
191+
Now if you run the script you'll see ``@``.
192+
193+
This code is sensitive to typing.
194+
If you wrote ``player_x=console.width / 2`` instead of ``player_x=console.width // 2`` (note the number of slashes) then ``player_x`` will be assigned as a float instead of an int.
195+
If ``player_x`` is a float then :any:`Console.print` will raise a TypeError.
196+
In this case the incorrect code is when ``ExampleState`` is created with an invalid type and not the print function call.
197+
Running ``mypy`` on your code will show you this type error at the correct position.
198+
Your IDE should also complain about a bad type if setup correctly.
199+
200+
The next step is to move the player on events.
201+
A new method will be added to the ``ExampleState`` for this called ``on_event``.
202+
``on_event`` takes a ``self`` and a :any:`tcod.event.Event` parameter and returns nothing.
203+
204+
Events are best handled using Python's `Structural Pattern Matching <https://peps.python.org/pep-0622/>`_.
205+
Consider reading `Python's Structural Pattern Matching Tutorial <https://peps.python.org/pep-0636/>`_.
206+
207+
Begin matching with ``match event:``.
208+
The equivalent to ``if isinstance(event, tcod.event.Quit):`` is ``case tcod.event.Quit():``.
209+
Keyboard keys can be checked with ``case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT):``.
210+
Make a case for each arrow key: ``LEFT`` ``RIGHT`` ``UP`` ``DOWN`` and move the player in the direction of that key.
211+
See :any:`KeySym` for a list of all keys.
212+
213+
.. code-block:: python
214+
215+
...
216+
@attrs.define(eq=False)
217+
class ExampleState:
218+
...
219+
220+
def on_event(self, event: tcod.event.Event) -> None:
221+
"""Move the player on events and handle exiting. Movement is hard-coded."""
222+
match event:
223+
case tcod.event.Quit():
224+
raise SystemExit()
225+
case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT):
226+
self.player_x -= 1
227+
case tcod.event.KeyDown(sym=tcod.event.KeySym.RIGHT):
228+
self.player_x += 1
229+
case tcod.event.KeyDown(sym=tcod.event.KeySym.UP):
230+
self.player_y -= 1
231+
case tcod.event.KeyDown(sym=tcod.event.KeySym.DOWN):
232+
self.player_y += 1
233+
...
234+
235+
Now replace the event handling code in ``main`` to defer to the states ``on_event`` method.
236+
237+
.. code-block:: python
238+
:emphasize-lines: 11
239+
240+
...
241+
def main() -> None:
242+
...
243+
state = ExampleState(player_x=console.width // 2, player_y=console.height // 2)
244+
with tcod.context.new(console=console, tileset=tileset) as context:
245+
while True:
246+
console.clear()
247+
state.on_draw(console)
248+
context.present(console)
249+
for event in tcod.event.wait():
250+
state.on_event(event) # Pass events to the state
251+
...
252+
253+
Now when you run this script you have a player character you can move around with the arrow keys before closing the window.
254+
255+
You can review the part-1 source code `here <https://github.com/HexDecimal/python-tcod-tutorial-2023/tree/part-1>`_.

0 commit comments

Comments
 (0)