19 KiB
Python Classes and Evennia Typeclasses
We have now learned how to run some simple Python code from inside (and outside) your game server. We have also taken a look at what our game dir looks and what is where. Now we'll start to use it.
Importing
No one writes something as big as an online game in one single huge file. Instead one breaks up the code into separate files (modules). Each module is dedicated to different purposes. Not only does it make things cleaner, organized and easier to understand. It also makes it easier to re-use code - you just import the resources you need and know you only get just what you requested. This makes it much easier to find errors and to know what code is good and which has issues.
Evennia itself uses your code in the same way - you just tell it where a particular type of code is, and it will import and use it (often instead of its defaults).
We have already successfully imported things, for example:
> py import world.test ; world.test.hello_world(me)
Hello World!
In this example, on your hard drive, the files looks like this:
mygame/
world/
test.py <- inside this file is a function hello_world
If you followed earlier tutorial lessons, the mygame/world/test.py file should look like this (if
not, make it so):
def hello_world(who):
who.msg("Hello World!")
- Indentation matters in Python
- So does capitalization
- Use 4 `spaces` to indent, not tabs
- Empty lines are fine
- Anything on a line after a `#` is a `comment`, ignored by Python
The python_path describes the relation between Python resources, both between and inside
Python modules (that is, files ending with .py). A python-path separates each part of the
path . and always skips the .py file endings. Also, Evennia already knows to start looking
for python resources inside mygame/ so this should never be specified. Hence
import world.test
The import Python instruction loads world.test so you have it available. You can now go "into"
this module to get to the function you want:
world.test.hello_world(me)
Using import like this means that you have to specify the full world.test every time you want
to get to your function. Here's a more powerful form of import:
from world.test import hello_world
The from ... import ... is very, very common as long as you want to get something with a longer
python path. It imports hello_world directly, so you can use it right away!
> py from world.test import hello_world ; hello_world(me)
Hello World!
Let's say your test.py module had a bunch of interesting functions. You could then import them
all one by one:
from world.test import hello_world, my_func, awesome_func
If there were a lot of functions, you could instead just import test and get the function
from there when you need (without having to give the full world.test every time):
> from world import test ; test.hello_world(me
Hello World!
You can also rename stuff you import. Say for example that the module you import to already
has a function hello_world but we also want to use the one from world/test.py:
from world.test import hello_world as test_hello_world
The form from ... import ... as ... renames the import.
> from world.test import hello_world as hw ; hw(me)
Hello World!
Avoid renaming unless it's to avoid a name-collistion like above - you want to make things as easy to read as possible, and renaming adds another layer of potential confusion.
In the basic intro to Python we learned how to open the in-game multi-line interpreter.
> py
Evennia Interactive Python mode
Python 3.7.1 (default, Oct 22 2018, 11:21:55)
[GCC 8.2.0] on Linux
[py mode - quit() to exit]
You now only need to import once to use the imported function over and over.
> from world.test import hello_world
> hello_world()
Hello World!
> hello_world()
Hello World!
> hello_world()
Hello World!
> quit()
Closing the Python console.
The same goes when writing code in a module - in most Python modules you will see a bunch of imports at the top, resources that are then used by all code in that module.
On classes and objects
Now that we know about imports, let look at a real Evennia module and try to understand it.
Open mygame/typeclasses/objects.py in your text editor of choice.
"""
module docstring
"""
from evennia import DefaultObject
class Object(DefaultObject):
"""
class docstring
"""
pass
A docstring is not the same as a comment (created by `#`). A
docstring is not ignored by Python but is an integral part of the thing
it is documenting (the module and the class in this case).
The real file is much longer but we can ignore the multi-line strings (""" ... """). These serve
as documentation-strings, or docstrings for the module (at the top) and the class below.
Below the module doc string we have the import. In this case we are importing a resource
from the core evennia library itself. We will dive into this later, for now we just treat this
as a black box.
Next we have a class named Object, which inherits from DefaultObject. This class doesn't
actually do anything on its own, its only code (except the docstring) is pass which means,
well, to pass and don't do anything.
To understand what we are looking at, we need to explain what a 'class', an 'object' and an 'instance' is.
Classes, objects, instances and inheritance are fundamental to Python. This and some
other concepts are often clumped together under the term Object-Oriented-Programming (OOP).
Classes and instances
A 'class' can be seen as a 'template' for a 'type' of object. The class describes the basic functionality
of everyone of that class. For example, we could have a class Mobile which has resources for moving itself
from room to room.
Open a new file mygame/typeclasses/mymobile.py. Add the following simple class:
class Mobile:
key = "Monster"
def move_around(self):
print(f"{self.key} is moving!")
Above we have defined a Mobile class with one variable key (that is, the name) and one
method on it. A method is like a function except it sits "on" the class. It also always has
at least one argument (almost always written as self although you could in principle use
another name), which is a reference back to itself. So when we print self.key we are referring
back to the key on the class.
A class is just a template. Before it can be used, we must create an instance of the class. If
Mobile is a class, then an instance is Fluffy, the individual red dragon. You instantiate
by calling the class, much like you would a function:
fluffy = Mobile()
Let's try it in-game (we use multi-line mode, it's easier)
> py
> from typeclasses.mymobile import Mobile
> fluffy = Mobile()
> fluffy.move_around()
Monster is moving!
We created an instance of Mobile, which we stored in the variable fluffy. We then
called the move_around method on fluffy to get the printout.
Note how we didn't call the method as
fluffy.move_around(self). While theselfhas to be there when defining the method, we never add it explicitly when we call the method (Python will add the correctselffor us automatically behind the scenes).
Let's create the sibling of Fluffy, Cuddly:
> cuddly = Mobile()
> cuddly.move_around()
Monster is moving!
We now have two dragons and they'll hang around until with call quit() to exit this Python
instance. We can have them move as many times as we want. But no matter how many dragons we
create, they will all show the same printout since key is always fixed as "Monster".
Let's make the class a little more flexible:
class Mobile:
def __init__(self, key):
self.key = key
def move_around(self):
print(f"{self.key} is moving!")
The __init__ is a special method that Python recognizes. If given, this handles extra arguments
when you instantiate a new Mobile. We have it add an argument key that we store on self.
Now, for Evennia to see this code change, we need to reload the server. You can either do it this way:
> quit()
Python Console is closing.
> reload
Or you can use a separate terminal and restart from outside the game:
Reloading with the python mode gets a little annoying since you need to redo everything
after every reload. Just keep in mind that during regular development you will not be
working this way. The in-game python mode is practical for quick fixes and experiments like
this, but actual code is normally written externally, in python modules.
$ evennia reload (or restart)
Either way you'll need to go into py again:
> py
> from typeclasses.mymobile import Mobile
fluffy = Mobile("Fluffy")
fluffy.move_around()
Fluffy is moving!
Now we passed "Fluffy" as an argument to the class. This went into __init__ and set self.key, which we
later used to print with the right name! Again, note that we didn't include self when calling.
What's so good about objects?
So far all we've seen a class do is to behave our first hello_world function but more complex. We
could just have made a function:
def mobile_move_around(key):
print(f"{key} is moving!")
The difference between the function and an instance of a class (the object), is that the object retains state. Once you called the function it forgets everything about what you called it with last time. The object, on the other hand, remembers changes:
> fluffy.key = "Cuddly"
> fluffy.move_around()
Cuddly is moving!
The fluffy object's key was changed to "Cuddly" for as long as it's around. This makes objects
extremely useful for representing and remembering collections of data - some of which can be other
objects in turn:
- A player character with all its stats
- A monster with HP
- A chest with a number of gold coins in it
- A room with other objects inside it
- The current policy positions of a political party
- A rule with methods for resolving challenges or roll dice
- A multi-dimenstional data-point for a complex economic simulation
- And so much more!
Classes can have children
Classes can inherit from each other. A "child" class will inherit everything from its "parent" class. But if the child adds something with the same name as its parent, it will override whatever it got from its parent.
Let's expand mygame/typeclasses/mymobile.py with another class:
class Mobile:
"""
This is a base class for Mobiles.
"""
def __init__(self, key):
self.key = key
def move_around(self):
print(f"{self.key} is moving!")
class Dragon(Mobile):
"""
This is a dragon-specific mobile.
"""
def move_around(self):
print(f"{self.key} flies through the air high above!")
def firebreath(self):
"""
Let our dragon breathe fire.
"""
print(f"{self.key} breathes fire!")
We added some docstrings for clarity. It's always a good idea to add doc strings; you can do so also for methods,
as exemplified for the new firebreath method.
We created the new class Dragon but we also specified that Mobile is the parent of Dragon but adding
the parent in parenthesis. class Classname(Parent) is the way to do this.
It's possible to add more comma-separated parents to a class. You should usually avoid
this until you `really` know what you are doing. A single parent will be enough for almost
every case you'll need.
Let's try out our new class. First reload the server and the do
> py
> from typeclasses.mobile import Dragon
> smaug = Dragon("Smaug")
> smaug.move_around()
Smaug flies through the air high above!
> smaug.firebreath()
Smaug breathes fire!
Because we didn't implement __init__ in Dragon, we got the one from Monster instead. But since we
implemented our own move_around in Dragon, it overrides the one in Monster. And firebreath is only
available for Dragons of course. Having that on Monster would not have made much sense, since not every monster
can breathe fire.
One can also force a class to use resources from the parent even if you are overriding some of it. This is done
with the super() method. Modify your Dragon class as follows:
# ...
class Dragon(Monster):
def move_around(self):
super().move_around()
print("The world trembles.")
# ...
Keep
Monsterand thefirebreathmethod,# ...indicates the rest of the code is untouched.
The super().move_around() line means that we are calling move_around() on the parent of the class. So in this
case, we will call Monster.move_around first, before doing our own thing.
Now reload the server and then:
> py
> from typeclasses.mobile import Dragon
> smaug = Dragon("Smaug")
> smaug.move_around()
Smaug is moving!
The world trembles.
We can see that Monster.move_around() is calls first and prints "Smaug is moving!", followed by the extra bit
about the trembling world we added in the Dragon class.
Inheritance is very powerful because it allows you to organize and re-use code while only adding the special things you want to change. Evennia uses this concept a lot.
Our first persistent object
Now we should know enough to understand what is happening in mygame/typeclasses/objects.py.
Open it again:
"""
module docstring
"""
from evennia import DefaultObject
class Object(DefaultObject):
"""
class docstring
"""
pass
So we have a class Object that inherits from DefaultObject, which we have imported from Evennia.
The class itself doesn't do anything (it just passes) but that doesn't mean it's useless. As we've seen,
it inherits all the functionality of its parent. It's in fact an exact replica of DefaultObject right now.
If we knew what kind of methods and resources were available on DefaultObject we could add our own and
change the way it works!
Hint: We will get back to this, but to learn what resources an Evennia parent like
DefaultObjectoffers, easiest is to peek at its API documentation. The docstring for theObjectclass can also help.
One thing that Evennia offers and which you don't get with vanilla Python classes is persistence. As you've found, Fluffy, Cuddly and Smaug are gone once we reload the server. Let's see if we can fix this.
Go back to mygame/typeclasses/mobile.py. Change it as follows:
from typeclasses.objects import Object
class Mobile(Object):
"""
This is a base class for Mobiles.
"""
def move_around(self):
print(f"{self.key} is moving!")
class Dragon(Mobile):
"""
This is a dragon-specific mobile.
"""
def move_around(self):
super().move_around()
print("The world trembles.")
def firebreath(self):
"""
Let our dragon breathe fire.
"""
print(f"{self.key} breathes fire!")
Don't forget to save. We removed Monster.__init__ and made Mobile inherit from Evennia's Object (which in turn
inherits from Evennia's DefaultObject, as we saw). By extension, this means that Dragon also inherits
from DefaultObject, just from further away!
Creating by calling the class (less common way)
First reload the server as usual. We will need to create the dragon a little differently this time:
Keyword arguments (like `db_key="Smaug"`) is a way to
name the input arguments to a function or method. They make
things easier to read but also allows for conveniently setting
defaults for values not given explicitly.
> py
> from typeclasses.mymobile import Dragon
> smaug = Dragon(db_key="Smaug", db_location=here)
> smaug.save()
> smaug.move_around()
Smaug is moving!
The world trembles.
Smaug works the same as before, but we created him differently: first we used
Dragon(db_key="Smaug", db_location=here) to create the object, and then we used smaug.save() afterwards.
> quit()
Python Console is closing.
> look
You should now see that Smaug is in the room with you. Woah!
> reload
> look
He's still there... What we just did is to create a new entry in the database for Smaug. We gave the object
its name (key) and set its location to our current location (remember that here is just something available
in the py command, you can't use it elsewhere).
To make use of Smaug in code we must first find him in the database. For an object in the current
location we can easily do this in py by using me.search():
> py smaug = me.search("Smaug") ; smaug.firebreath()
Smaug breathes fire!
Creating using create_object
Creating Smaug like we did above is nice because it's similar to how we created non-database
bound Python instances before. But you need to use db_key instead of key and you also have to
remember to call .save() afterwards. Evennia has a helper function that is more common to use,
called create_object:
> py fluffy = evennia.create_object('typeclases.mymobile.Mobile', key="Fluffy", location=here)
> look
Boom, Fluffy should now be in the room with you, a little less scary than Smaug. You specify the python-path to the code you want and then set the key and location. Evennia sets things up and saves for you.
If you want to find Fluffy from anywhere, you can use Evennia's search_object helper:
> fluffy = evennia.search_object("Fluffy")[0] ; fluffy.move_around()
Fluffy is moving!
The
[0]is becausesearch_objectalways returns a list of zero, one or more found objects. The[0]means that we want the first element of this list (counting in Python always starts from 0). If there were multiple Fluffies we could get the second one with[1].
Creating using create-command
Finally, you can also create a new Dragon using the familiar builder-commands we explored a few lessons ago:
> create/drop Cuddly:typeclasses.mymobile.Mobile
Cuddly is now in the room. After learning about how objects are created you'll realize that all this command really
does is to parse your input, figure out that /drop means to "give the object the same location as the caller",
and then do a call akin to
evennia.create_object("typeclasses.mymobile.Mobile", key="Cuddly", location=here)
That's pretty much all there is to the mighty create command.
... And speaking of Commands, we should try to add one of our own next.