-
Notifications
You must be signed in to change notification settings - Fork 7
Roadmap
We have started our adventure by fooling around with the creation of characters, using finite state machines. While it's good fun to see those characters live it's now time to consider what is required in order to build our text adventure game.
I will try here to define the basic features we need. We need to start small and add new features rather than drown under too many details.
Our hero needs to be able to move around. There has to be some concepts of location. For each location there should be a description to explain to the player what his environment is. There is also a need for a way to allow him to travel to another location from where he is.
This could already be our first goal. There is no end of game condition yet. Our hero would wander endlessly in a few locations.
As in most games, we will need some sort of infinite loop consisting of three steps:
- Read and process the player inputs
- Update the world
- Render the game
The structure will probably loosely follow the MVC (Model-View-Controller) pattern. Here is the gist of it:
The Model will only be concerned with the data and logic of the game itself. It knows nothing about player input, rendering or anything like this. It basically lives in isolation and is manipulated by the two other components. It will know what the player location is, what the player inventory is, etc.
The View knows how to render the Model. As it's a console based application, it will render with print
statements. It will not know what it needs to render. It just knows how to render whatever it is told to render. As such the view will need to be aware of the model in order to get the information it needs for the rendering. As a quick example, the view might have a method show_location()
in which it will ask the model for the current location description and prints it to the screen. But it can also have methods like show_inventory()
or show_item()
which would respectively print the player inventory and an item description. The view is manipulated by the controller.
The controller is the part which knows about all the other parts. It's the glue between them. The controller will process player inputs and manipulate the model according to what the player wants to do. If the player wants to change location, the controller will tell the model to change location. Then the controller will tell the view to render the new location. So it's the controller who manipulates both the model and the view.
We will create a command which will exit the game. This will prove useful during development. This is also the only way at the moment we are going to exit our infinite loop.
What objects would we need? We need one or more models, a view and a controller. I say one or more models because we don't cram all our data in a single object. We can split it according to their different responsibilities. What sort of models do we need? It seems we need a Location
object. What are its responsibilities?
- Hold a description of itself
- Hold a sequence of other
Location
names the player can visit from here
Then we need to keep track of all the different Location
s. That's the responsibility of the World
object:
- Hold a reference to all the
Location
s. - Retrieve the correct
Location
based on a unique identifier (the location's name).
Then we need a Player
object. What are its responsibilities?
- At the moment it only needs to know where it is located
- It needs to be able to move to another location provided it can get there from where it stands. This means he will need to hold a reference to the
World
. It will ask it for the newLocation
s when he wants to move and will update its current location.
Our code should be tested. I would suggest we use pytest for that. Tests would be located in a separate folder named test
. For our tests to properly find our text_adventure
package, there are a couple of things we can do:
- Add the package manually to
sys.path
, so Python can find it when we try to load it. This what I've chosen with the following code:
# Insert `text_adventure` package to the sys.path so we don't need to
# install the package for testing.
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
- Install the text_adventure package in development mode. To install it, we would need to create a
setup.py
file at the root of our project. We would use the setuptoolssetup
function to make the package installable. Then from the command-line we would install it by invokingpython -e setup.py install
. The-e
means that python would only install an egg in the python site-packages folder which would simply point to our current folder. So any changes we are making to the text_adventure package would directly be visible to python. This is something we might do in the future, but not right now.
It's sometimes tricky to test parts of the code where you need to simulate player inputs or to check for the proper output on the screen. In pytest, there are two fixtures that allow you to mock the input and to intercept stdout for your test. The first one is called monkeypatch. It allows to temporarily replace a function with something else. In our case, it would be a function which just returns a pre-defined string for simulating player input. Here is a small example:
def ask_name():
name = input("Enter your name: ")
return name.lower()
def test_ask_name(monkeypatch):
monkeypatch.setitem(__builtins__, 'input', lambda x: "Dan")
output = ask_name()
assert output == "dan"
We replace __builtins__.input
with a lambda function which takes one argument and returns the string "Dan"
. When we call the ask_name()
function, input
will actually call the lambda function instead of the regular input
function. We can then test for the function output. As soon as we leave the test, the input
function is restored to its default behaviour.
The second one is capsys which allows to capture both stdout and stderr. Here is an example:
def display_name(name):
print("Glad to see you {}!".format(name.capitalize()))
def test_display_name(capsys):
name = "daniel"
display_name(name)
out, err = capsys.readouterr()
assert out == "Glad to see you Daniel!\n"
When calling capsys.readouterr()
we receive a tuple with the content of stdout and stderr captured up to this point. The capturing doesn't stop so you can call later on capsys.readouterr()
to retrieve more text.
Unit testing is all about testing one unit in isolation. Sometimes this proves to be difficult as the unit is entangled with other dependencies internally. To understand what I mean, imagine the test needed for our main game loop. The game loop is a function which only exits when the game is over. And in the meantime, it will call our controller to process the player input which will update the world (models) and ask the view to render the current game state. But we don't want to test all this at once! This is a unit test, and the unit is only about testing the loop and make sure it exits when the game over condition is met. The gaming loop could look something like this:
To be continued...