A world of objects#
An object orientated text adventure game#
Back in the late 80s and early 90s a popular type of game on micro-computers, such as the Commodore 64 and ZX Spectrum, was the text adventure. These were games where a player had to navigate and solve puzzles in a game world with only text descriptions of locations as their guide!
These types of games lend themselves nicely to abstraction offered in object orientated methods of design (and python command line interfaces). For example, in a very simple implementation we could have a class of type Game
that accepts a limited number of commands (for example, “n” to represent ‘go north’ or “look” to represent ‘look and describe the room’.). The Game
would encapsulate one or more Room
objects that describes a location and contain exits to other Room
objects.
A text hospital adventure#
Before we look at creating instances of objects, let’s first take a look at a basic game. In this game we are limited to just exploring. We can move around a small hospital setting by issuing commands to move.
Imports#
The function get_hospital_textworld returns a TextWorld
object. It is setup for the hospital text adventure we can play.
from text_adventure.basic_game import get_hospital_textworld
adventure = get_hospital_textworld()
print(adventure)
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
legal_commands=['look', 'quit'],
current_room=Room(name='reception', description='You are stood in the', n_exits=1),
max_moves=5)
A function to play the game#
def play_text_adventure(adventure):
'''
Play your text adventure!
'''
print('********************************************')
print(adventure.opening, end='\n\n')
print(adventure.current_room.describe())
while adventure.active:
user_input = input("\nWhat do you want to do? >>> ")
response = adventure.take_action(user_input)
print(response)
print('Game over!')
#uncomment to play
#play_text_adventure(adventure)
Text Hospital - let’s build it using Room
objects#
We will start by creating a text based hospital world. The game will be comprised by network of four Room
objects: a reception, a corridor, a ward and the operating theatre.
Imports#
from text_adventure.basic_game import TextWorld, Room
Setting and getting attributes and methods#
Each object we will instantiate has its own attribute and methods. You have come across these before in a different context.
An attribute represents a variable that is local to the object. For example, each Room
has a description
attribute. You can access the attribute by following the object name with a ‘.’ and then name of the attribute.
A method is the same as a function, but it is again attached to the object. For example, objects of type Room
have a add_exit(room, direction)
method that allows you to pass in another Room object and a direction of travel from the current room (these are stored in the attribute exit - a dict).
# Let's instantiate some Room objects to represent our network of rooms
#start fo the game = reception
reception = Room(name="reception")
reception.description = """You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas.
To the north is a corridor."""
corridor = Room(name='corridor')
corridor.description = """A long corridor branching in three directions.
To the north is signposted 'WARD'.
The south is signposted 'RECEPTION'.
The east is signposted 'THEATRE'"""
ward = Room(name="ward")
ward.description = """You are on the general medical ward. There are 10 beds
and all seem to be full today. There is a smell of disinfectant.
The exit is to the south"""
theatre = Room(name="theatre")
theatre.description = """You are in the operating theatre. Its empty today as
all of the elective operations have been cancelled.
An exit is to the west."""
#add the exits by calling the add_exit() method
reception.add_exit(corridor, 'n')
corridor.add_exit(reception, 's')
corridor.add_exit(ward, 'n')
corridor.add_exit(theatre, 'e')
ward.add_exit(corridor, 's')
theatre.add_exit(corridor, 'w')
rooms_collection = [reception, corridor, ward, theatre]
print(reception)
Room(name='reception', description='You are stood in the', n_exits=1)
#let's take a look at the description of reception via its attribute
print(reception.description)
You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas.
To the north is a corridor.
print(reception.describe())
You are stood in the busy hospital reception.
To the south, east and west are wards with COVID19 restricted areas.
To the north is a corridor.
#reception only has a single exit
reception.exits
{'n': Room(name='corridor', description='A long corridor bran', n_exits=3)}
#corridor has three exits
corridor.exits
{'s': Room(name='reception', description='You are stood in the', n_exits=1),
'n': Room(name='ward', description='You are on the gener', n_exits=1),
'e': Room(name='theatre', description='You are in the opera', n_exits=1)}
#create the game room
adventure = TextWorld(name='text hospital world', rooms=rooms_collection,
start_index=0)
#set the legal commands for the game
#directions a player can move and command they can issue.
adventure.legal_commands = ['look', 'quit']
adventure.legal_exits = ['n', 'e', 's', 'w']
adventure.opening = """Welcome to your local hospital! Unfortunatly due to the
pandemic most of the hospital is under restrictions today. But there are a few
areas where it is safe to visit.
"""
print(adventure)
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
legal_commands=['look', 'quit'],
current_room=Room(name='reception', description='You are stood in the', n_exits=1),
max_moves=5)
#play_text_adventure(adventure)
How to build a class in python#
Now that we have learnt how to instantiate objects and use frameworks of python classes we need to learn how to code a class.
We will start with a very simple example and then take a look at the Room
class from our text adventure framework.
The world’s most simple python class#
We declare a class in python using the class
keyword.
#the world's most simple `Patient` class!
class Patient:
pass
#create an object of type `Patient`
new_patient = Patient()
#in python we can dynamically add attributes (and methods)
new_patient.name = 'Tom'
new_patient.occupation = 'data scientist'
print(new_patient.name)
Tom
Most classes have a constructor __init__()
method#
In most classes I code, I include an __init__()
method. It is the method called when you create an instance of the class. This is sometimes called a contructor method, as it is used when an object is constructed. A simple example is below.
Note the use of the argument self
. This is a special method parameter that must be included as the first parameter in all methods in your class. self
is the way an object internally references itself. If you need your class to have an attribute called name
then you refer to it as self.name
. This means that any method in the class can access the attribute.
class Patient:
def __init__(self):
self.name = 'joe bloggs'
self.occupation = 'coder'
patient2 = Patient()
print(patient2.name)
print(patient2.occupation)
joe bloggs
coder
including parameters in the constructor method#
class Patient:
def __init__(self, name, occupation, age):
self.name = name
self.occupation = occupation
self.age = age
#example of an attribute that is not set by the constructor
#but still needs to be initialised.
self.triage_band = None
patient3 = Patient('Joe Bloggs', 'ex-coder', 87)
print(patient3.name)
print(patient3.occupation)
print(patient3.age)
print(patient3.triage_band)
Joe Bloggs
ex-coder
87
None
The Room
class from the Hospital Basic Text Adventure#
class Room:
'''
Encapsulates a location/room within a TextWorld.
A `Room` has a number of exits to other `Room` objects
'''
def __init__(self, name):
self.name = name
self.description = ""
self.exits = {}
def add_exit(self, room, direction):
'''
Add an exit to the room
Params:
------
room: Room
a Room object to link
direction: str
The str command to access the room
'''
self.exits[direction] = room
def exit(self, direction):
'''
Exit the room in the specified direction
Params:
------
direction: str
A command string representing the direction.
'''
if direction in self.exits:
return self.exits[direction]
else:
raise ValueError()
def describe(self):
'''
Describe the room to a player
'''
return self.description
The TextWorld
class#
class TextWorld:
'''
A TextWorld encapsulate the logic and Room objects that comprise the game.
'''
def __init__(self, name, rooms, start_index=0, max_moves=5):
'''
Constructor method for World
Parameters:
----------
rooms: list
A list of rooms in the world.
start_index: int, optional (default=0)
The index of the room where the player begins their adventure.
'''
self.name = name
self.rooms = rooms
self.current_room = self.rooms[start_index]
self.legal_exits = ['n', 'e', 's', 'w']
self.legal_commands =['look', 'quit']
self.n_actions = 0
#true while the game is active.
self.active = True
#limit the number of move before the game ends.
self.max_moves = max_moves
def take_action(self, command):
'''
Take an action in the TextWorld
Parameters:
-----------
command: str
A command to parse and execute as a game action
Returns:
--------
str: a string message to display to the player.
'''
#no. of actions taken
self.n_actions += 1
if self.n_actions == self.max_moves:
self.active = False
#handle action to move room
if command in self.legal_exits:
msg = ''
try:
self.current_room = self.current_room.exit(command)
msg = self.current_room.description
except ValueError:
msg = 'You cannot go that way.'
finally:
return msg
#split into array
parsed_command = command.split()
if parsed_command[0] in self.legal_commands:
#handle command
if parsed_command[0] == 'look':
return self.current_room.describe()
elif parsed_command[0] == 'quit':
self.active = False
return 'You have quit the game.'
else:
#handle command error
return f"I don't know how to {command}"
More complex OO frameworks#
Classes are customised by Inheritance#
A note of caution: Over time I’ve learnt to be somewhat wary of complex multiple inheritance structures in any programming language. Inheritance brings huge benefits in terms of code reuse, but you also need to learn good OO design principals in order to avoid unexpected dependencies in your code and avoid major rework due to small changes in a projects requirements.
Let’s work with a simple patient based example first. In the code below we create a class called Patient
. The class StrokePatient
inherits from Patient
. In python’s OO terminology we called Patient
a super class and StrokePatient
a subclass. StrokePatient
inherits all of the super class methods and attributes. In effect StrokePatient
is-a specialisation of Patient
. The code below illustrates how this works.
import random
#`Patient` is refered to as a 'super class'
class Patient:
def __init__(self, name, occupation, age):
self.name = name
self.occupation = occupation
self.age = age
self.triage_band = None
def set_random_triage_band(self):
'''set a random triage band 1 - 5'''
self.triage_band = random.randint(1, 5)
#subclass `StrokePatient`
class StrokePatient(Patient):
def __init__(self, name, occupation, age, stroke_type=1):
#call the constructor of the superclass
super().__init__(name, occupation, age)
self.stroke_type = stroke_type
#create an instance of a `StrokePatient` and use inherited methods
random.seed(42)
new_patient = StrokePatient('Joe Blogs', 'Teacher', 45)
new_patient.set_random_triage_band()
print(new_patient.name)
print(new_patient.triage_band)
Joe Blogs
1
Has-a: An alternative OO design.#
In the previous example StrokePatient
is-a specialisation of Patient
. An alternative way to frame this design problem as one of object composition where a StrokeCase
has-a Patient
.
This approach provides slightly more flexibility than direct inheritance. For example you can pass in a different type of object as long as it implements the same interface. It does, however, require a bit more code to setup.
The code below introduces a bit of OOP syntax called a getter property for accessing
StrokeCase.triage_band
. Externally to the objecttriage_band
appears and behaves very much like a class attribute. However in the implementation we can see that it isn’t and we can use code and logic if we want. Here we are simply calling thePatient.triage_band
.
#this time patient is a parameter instead of a superclass
class StrokeCase:
def __init__(self, patient, stroke_type=1):
self.patient = patient
self.stroke_type = stroke_type
@property
def triage_band(self):
return self.patient.triage_band
def set_random_triage_band(self):
self.patient.set_random_triage_band()
random.seed(101)
new_patient = Patient('Joe Bloggs', 'Teacher', 45)
stroke = StrokeCase(new_patient)
stroke.set_random_triage_band()
print(stroke.triage_band)
5
Using inheritance to allow a Room
and a game player to hold inventory#
Now that we understand inheritance and object composition let’s take look at a modified text adventure hospital. In this version of the code we introduce the concept of inventory. Have a go at playing the game. You will find items of inventory scattered around the rooms in the game. You can interact with these items by issuing the get
, drop
, and ex
(examine) commands. To view what inventory you are holding issue the inv
command.
from text_adventure.advanced_game import hospital_with_inventory
game = hospital_with_inventory()
game
TextWorld(name='text hospital world', n_rooms=4, legal_exits=['n', 'e', 's', 'w'],
legal_commands=['look', 'inv', 'get', 'drop', 'ex', 'quit'],
current_room=Room(name='reception', description='You are stood in the', n_exits=1, n_items=1))
#play_text_adventure(game)
OOP Implementation#
The game is now a little bit more fun than before (although that isn’t saying much). Let’s take a look at how the code is implemented.
An item of inventory is represented by a class called
InventoryItem
.A
Room
and aTextWorld
is-aInventoryHolder
An object that is-a
InventoryHolder
holds references toInventoryItem
In other words we have used inheritance to add some abilities to Room
and TextWorld
to hold multiple instances of InventoryItem
. First we define two new classes: InventoryItem
and InventoryItemHolder
.
class InventoryItem:
'''
An item found in a text adventure world that can be picked up
or dropped.
'''
def __init__(self, short_description):
self.name = short_description
self.long_description = ''
self.aliases = []
def add_alias(self, new_alias):
'''
Add an alias (alternative name) to the InventoryItem.
For example if an inventory item has the short description
'credit card' then a useful alias is 'card'.
Parameters:
-----------
new_alias: str
The alias to add. For example, 'card'
'''
self.aliases.append(new_alias)
class InventoryHolder:
'''
Encapsulates the logic for adding and removing an InventoryItem
This simulates "picking up" and "dropping" items in a TextWorld
'''
def __init__(self):
#inventory just held in a list interally
self.inventory = []
def list_inventory(self):
'''
Return a string representation of InventoryItems held.
'''
msg = ''
for item in self.inventory:
msg += f'{item.name}\n'
return msg
def add_inventory(self, item):
'''
Add an InventoryItem
'''
self.inventory.append(item)
def get_inventory(self, item_name):
'''
Returns an InventoryItem from Room.
Removes the item from the Room's inventory
Params:
------
item_name: str
Key identifying item.
Returns
-------
InventoryItem
'''
selected_item, selected_index = self.find_inventory(item_name)
#remove at index and return
del self.inventory[selected_index]
return selected_item
def find_inventory(self, item_name):
'''
Find an inventory item and return it and its index
in the collection.
Returns:
-------
Tuple: InventoryItem, int
Raises:
------
KeyError
Raised when an InventoryItem without a matching alias.
'''
selected_item = None
selected_index = -1
for index, item in zip(range(len(self.inventory)), self.inventory):
if item_name in item.aliases:
selected_item = item
selected_index = index
break
if selected_item == None:
raise KeyError('You cannot do that.')
return selected_item, selected_index
In summary, InventoryItem
is just a textual description of an item plus a collection of aliases. This allows a game player to interact with the item through a number of names. For example, a “banana” might be aliased as ‘fruit’ or ‘banana’.
InventoryHolder
provides all the functionality to manage a collection of InventoryItem
objects. So we can add, remove, and list all, items from a InventoryHolder
object’s internal collection.
Any class that inherits from InventoryHolder
can now manage a collection of items that they ‘own’. Neat! In our enhanced game both Room
and TextWorld
inherit this functionality. The TextWorld
object here used as a surrogate for a player. The modified code listing for Room
is provided below.
class Room(InventoryHolder):
'''
Encapsulates a location/room within a TextWorld.
A `Room` has a number of exits to other `Room` objects
A `Room` is-a type of `InventoryHolder`
'''
def __init__(self, name):
'''
Room constructor
'''
self.name = name
self.description = ""
self.exits = {}
#MODIFICATION
super().__init__()
def add_exit(self, room, direction):
'''
Add an exit to the room
Params:
------
room: Room
a Room object to link
direction: str
The str command to access the room
'''
self.exits[direction] = room
def exit(self, direction):
'''
Exit the room in the specified direction
Params:
------
direction: str
A command string representing the direction.
'''
if direction in self.exits:
return self.exits[direction]
else:
raise ValueError()
def describe(self):
msg = self.description
### MODIFICATION
if len(self.inventory) > 0:
msg += '\nYou can also see:\n'
msg += self.list_inventory()
return msg