mirror of
https://github.com/evennia/evennia.git
synced 2026-03-22 15:56:30 +01:00
Updated HTML docs.
This commit is contained in:
parent
59e50f3fa5
commit
06bc3c8bcd
663 changed files with 2 additions and 61705 deletions
|
|
@ -1,414 +0,0 @@
|
|||
# Player Characters
|
||||
|
||||
In the [previous lesson about rules and dice rolling](./Beginner-Tutorial-Rules.md) we made some
|
||||
assumptions about the "Player Character" entity:
|
||||
|
||||
- It should store Abilities on itself as `character.strength`, `character.constitution` etc.
|
||||
- It should have a `.heal(amount)` method.
|
||||
|
||||
So we have some guidelines of how it should look! A Character is a database entity with values that
|
||||
should be able to be changed over time. It makes sense to base it off Evennia's
|
||||
[DefaultCharacter Typeclass](../../../Components/Typeclasses.md). The Character class is like a 'character sheet' in a tabletop
|
||||
RPG, it will hold everything relevant to that PC.
|
||||
|
||||
## Inheritance structure
|
||||
|
||||
Player Characters (PCs) are not the only "living" things in our world. We also have _NPCs_
|
||||
(like shopkeepers and other friendlies) as well as _monsters_ (mobs) that can attack us.
|
||||
|
||||
In code, there are a few ways we could structure this. If NPCs/monsters were just special cases of PCs,
|
||||
we could use a class inheritance like this:
|
||||
|
||||
```python
|
||||
from evennia import DefaultCharacter
|
||||
|
||||
class EvAdventureCharacter(DefaultCharacter):
|
||||
# stuff
|
||||
|
||||
class EvAdventureNPC(EvAdventureCharacter):
|
||||
# more stuff
|
||||
|
||||
class EvAdventureMob(EvAdventureNPC):
|
||||
# more stuff
|
||||
```
|
||||
|
||||
All code we put on the `Character` class would now be inherited to `NPC` and `Mob` automatically.
|
||||
|
||||
However, in _Knave_, NPCs and particularly monsters are _not_ using the same rules as PCs - they are
|
||||
simplified to use a Hit-Die (HD) concept. So while still character-like, NPCs should be separate from
|
||||
PCs like this:
|
||||
|
||||
```python
|
||||
from evennia import DefaultCharacter
|
||||
|
||||
class EvAdventureCharacter(DefaultCharacter):
|
||||
# stuff
|
||||
|
||||
class EvAdventureNPC(DefaultCharacter):
|
||||
# separate stuff
|
||||
|
||||
class EvAdventureMob(EvadventureNPC):
|
||||
# more separate stuff
|
||||
```
|
||||
|
||||
Nevertheless, there are some things that _should_ be common for all 'living things':
|
||||
|
||||
- All can take damage.
|
||||
- All can die.
|
||||
- All can heal
|
||||
- All can hold and lose coins
|
||||
- All can loot their fallen foes.
|
||||
- All can get looted when defeated.
|
||||
|
||||
We don't want to code this separately for every class but we no longer have a common parent
|
||||
class to put it on. So instead we'll use the concept of a _mixin_ class:
|
||||
|
||||
```python
|
||||
from evennia import DefaultCharacter
|
||||
|
||||
class LivingMixin:
|
||||
# stuff common for all living things
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
# stuff
|
||||
|
||||
class EvAdventureNPC(LivingMixin, DefaultCharacter):
|
||||
# stuff
|
||||
|
||||
class EvAdventureMob(LivingMixin, EvadventureNPC):
|
||||
# more stuff
|
||||
```
|
||||
|
||||
```{sidebar}
|
||||
In [evennia/contrib/tutorials/evadventure/characters.py](../../../api/evennia.contrib.tutorials.evadventure.characters.md)
|
||||
is an example of a character class structure.
|
||||
```
|
||||
Above, the `LivingMixin` class cannot work on its own - it just 'patches' the other classes with some
|
||||
extra functionality all living things should be able to do. This is an example of
|
||||
_multiple inheritance_. It's useful to know about, but one should not over-do multiple inheritance
|
||||
since it can also get confusing to follow the code.
|
||||
|
||||
## Living mixin class
|
||||
|
||||
> Create a new module `mygame/evadventure/characters.py`
|
||||
|
||||
Let's get some useful common methods all living things should have in our game.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/characters.py
|
||||
|
||||
from .rules import dice
|
||||
|
||||
class LivingMixin:
|
||||
|
||||
# makes it easy for mobs to know to attack PCs
|
||||
is_pc = False
|
||||
|
||||
def heal(self, hp):
|
||||
"""
|
||||
Heal hp amount of health, not allowing to exceed our max hp
|
||||
|
||||
"""
|
||||
damage = self.hp_max - self.hp
|
||||
healed = min(damage, hp)
|
||||
self.hp += healed
|
||||
|
||||
self.msg("You heal for {healed} HP.")
|
||||
|
||||
def at_pay(self, amount):
|
||||
"""When paying coins, make sure to never detract more than we have"""
|
||||
amount = min(amount, self.coins)
|
||||
self.coins -= amount
|
||||
return amount
|
||||
|
||||
def at_damage(self, damage, attacker=None):
|
||||
"""Called when attacked and taking damage."""
|
||||
self.hp -= damage
|
||||
|
||||
def at_defeat(self):
|
||||
"""Called when defeated. By default this means death."""
|
||||
self.at_death()
|
||||
|
||||
def at_death(self):
|
||||
"""Called when this thing dies."""
|
||||
# this will mean different things for different living things
|
||||
pass
|
||||
|
||||
def at_do_loot(self, looted):
|
||||
"""Called when looting another entity"""
|
||||
looted.at_looted(self)
|
||||
|
||||
def at_looted(self, looter):
|
||||
"""Called when looted by another entity"""
|
||||
|
||||
# default to stealing some coins
|
||||
max_steal = dice.roll("1d10")
|
||||
stolen = self.at_pay(max_steal)
|
||||
looter.coins += stolen
|
||||
|
||||
```
|
||||
Most of these are empty since they will behave differently for characters and npcs. But having them
|
||||
in the mixin means we can expect these methods to be available for all living things.
|
||||
|
||||
|
||||
## Character class
|
||||
|
||||
We will now start making the basic Character class, based on what we need from _Knave_.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/characters.py
|
||||
|
||||
from evennia import DefaultCharacter, AttributeProperty
|
||||
from .rules import dice
|
||||
|
||||
class LivingMixin:
|
||||
# ...
|
||||
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
"""
|
||||
A character to use for EvAdventure.
|
||||
"""
|
||||
is_pc = True
|
||||
|
||||
strength = AttributeProperty(1)
|
||||
dexterity = AttributeProperty(1)
|
||||
constitution = AttributeProperty(1)
|
||||
intelligence = AttributeProperty(1)
|
||||
wisdom = AttributeProperty(1)
|
||||
charisma = AttributeProperty(1)
|
||||
|
||||
hp = AttributeProperty(8)
|
||||
hp_max = AttributeProperty(8)
|
||||
|
||||
level = AttributeProperty(1)
|
||||
xp = AttributeProperty(0)
|
||||
coins = AttributeProperty(0)
|
||||
|
||||
def at_defeat(self):
|
||||
"""Characters roll on the death table"""
|
||||
if self.location.allow_death:
|
||||
# this allow rooms to have non-lethal battles
|
||||
dice.roll_death(self)
|
||||
else:
|
||||
self.location.msg_contents(
|
||||
"$You() $conj(collapse) in a heap, alive but beaten.",
|
||||
from_obj=self)
|
||||
self.heal(self.hp_max)
|
||||
|
||||
def at_death(self):
|
||||
"""We rolled 'dead' on the death table."""
|
||||
self.location.msg_contents(
|
||||
"$You() collapse in a heap, embraced by death.",
|
||||
from_obj=self)
|
||||
# TODO - go back into chargen to make a new character!
|
||||
```
|
||||
|
||||
We make an assumption about our rooms here - that they have a property `.allow_death`. We need
|
||||
to make a note to actually add such a property to rooms later!
|
||||
|
||||
In our `Character` class we implement all attributes we want to simulate from the _Knave_ ruleset.
|
||||
The `AttributeProperty` is one way to add an Attribute in a field-like way; these will be accessible
|
||||
on every character in several ways:
|
||||
|
||||
- As `character.strength`
|
||||
- As `character.db.strength`
|
||||
- As `character.attributes.get("strength")`
|
||||
|
||||
See [Attributes](../../../Components/Attributes.md) for seeing how Attributes work.
|
||||
|
||||
Unlike in base _Knave_, we store `coins` as a separate Attribute rather than as items in the inventory,
|
||||
this makes it easier to handle barter and trading later.
|
||||
|
||||
We implement the Player Character versions of `at_defeat` and `at_death`. We also make use of `.heal()`
|
||||
from the `LivingMixin` class.
|
||||
|
||||
### Funcparser inlines
|
||||
|
||||
This piece of code is worth some more explanation:
|
||||
|
||||
```python
|
||||
self.location.msg_contents(
|
||||
"$You() $conj(collapse) in a heap, alive but beaten.",
|
||||
from_obj=self)
|
||||
```
|
||||
|
||||
Remember that `self` is the Character instance here. So `self.location.msg_contents` means "send a
|
||||
message to everything inside my current location". In other words, send a message to everyone
|
||||
in the same place as the character.
|
||||
|
||||
The `$You() $conj(collapse)` are [FuncParser inlines](../../../Components/FuncParser.md). These are functions that
|
||||
execute
|
||||
in the string. The resulting string may look different for different audiences. The `$You()` inline
|
||||
function will use `from_obj` to figure out who 'you' are and either show your name or 'You'.
|
||||
The `$conj()` (verb conjugator) will tweak the (English) verb to match.
|
||||
|
||||
- You will see: `"You collapse in a heap, alive but beaten."`
|
||||
- Others in the room will see: `"Thomas collapses in a heap, alive but beaten."`
|
||||
|
||||
Note how `$conj()` chose `collapse/collapses` to make the sentences grammatically correct.
|
||||
|
||||
### Backtracking
|
||||
|
||||
We make our first use of the `rules.dice` roller to roll on the death table! As you may recall, in the
|
||||
previous lesson, we didn't know just what to do when rolling 'dead' on this table. Now we know - we
|
||||
should be calling `at_death` on the character. So let's add that where we had TODOs before:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/rules.py
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
# ...
|
||||
|
||||
def roll_death(self, character):
|
||||
ability_name = self.roll_random_table("1d8", death_table)
|
||||
|
||||
if ability_name == "dead":
|
||||
# kill the character!
|
||||
character.at_death() # <------ TODO no more
|
||||
else:
|
||||
# ...
|
||||
|
||||
if current_ability < -10:
|
||||
# kill the character!
|
||||
character.at_death() # <------- TODO no more
|
||||
else:
|
||||
# ...
|
||||
```
|
||||
|
||||
## Connecting the Character with Evennia
|
||||
|
||||
You can easily make yourself an `EvAdventureCharacter` in-game by using the
|
||||
`type` command:
|
||||
|
||||
type self = evadventure.characters.EvAdventureCharacter
|
||||
|
||||
You can now do `examine self` to check your type updated.
|
||||
|
||||
If you want _all_ new Characters to be of this type you need to tell Evennia about it. Evennia
|
||||
uses a global setting `BASE_CHARACTER_TYPECLASS` to know which typeclass to use when creating
|
||||
Characters (when logging in, for example). This defaults to `typeclasses.characters.Character` (that is,
|
||||
the `Character` class in `mygame/typeclasses/characters.py`).
|
||||
|
||||
There are thus two ways to weave your new Character class into Evennia:
|
||||
|
||||
1. Change `mygame/server/conf/settings.py` and add `BASE_CHARACTER_CLASS = "evadventure.characters.EvAdventureCharacter"`.
|
||||
2. Or, change `typeclasses.characters.Character` to inherit from `EvAdventureCharacter`.
|
||||
|
||||
You must always reload the server for changes like this to take effect.
|
||||
|
||||
```{important}
|
||||
In this tutorial we are making all changes in a folder `mygame/evadventure/`. This means we can isolate
|
||||
our code but means we need to do some extra steps to tie the character (and other objects) into Evennia.
|
||||
For your own game it would be just fine to start editing `mygame/typeclasses/characters.py` directly
|
||||
instead.
|
||||
```
|
||||
|
||||
|
||||
## Unit Testing
|
||||
|
||||
> Create a new module `mygame/evadventure/tests/test_characters.py`
|
||||
|
||||
For testing, we just need to create a new EvAdventure character and check
|
||||
that calling the methods on it doesn't error out.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/tests/test_characters.py
|
||||
|
||||
from evennia.utils import create
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
|
||||
from ..characters import EvAdventureCharacter
|
||||
|
||||
class TestCharacters(BaseEvenniaTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.character = create.create_object(EvAdventureCharacter, key="testchar")
|
||||
|
||||
def test_heal(self):
|
||||
self.character.hp = 0
|
||||
self.character.hp_max = 8
|
||||
|
||||
self.character.heal(1)
|
||||
self.assertEqual(self.character.hp, 1)
|
||||
# make sure we can't heal more than max
|
||||
self.character.heal(100)
|
||||
self.assertEqual(self.character.hp, 8)
|
||||
|
||||
def test_at_pay(self):
|
||||
self.character.coins = 100
|
||||
|
||||
result = self.character.at_pay(60)
|
||||
self.assertEqual(result, 60)
|
||||
self.assertEqual(self.character.coins, 40)
|
||||
|
||||
# can't get more coins than we have
|
||||
result = self.character.at_pay(100)
|
||||
self.assertEqual(result, 40)
|
||||
self.assertEqual(self.character.coins, 0)
|
||||
|
||||
# tests for other methods ...
|
||||
|
||||
```
|
||||
If you followed the previous lessons, these tests should look familiar. Consider adding
|
||||
tests for other methods as practice. Refer to previous lessons for details.
|
||||
|
||||
For running the tests you do:
|
||||
|
||||
evennia test --settings settings.py .evadventure.tests.test_character
|
||||
|
||||
|
||||
## About races and classes
|
||||
|
||||
_Knave_ doesn't have any D&D-style _classes_ (like Thief, Fighter etc). It also does not bother with
|
||||
_races_ (like dwarves, elves etc). This makes the tutorial shorter, but you may ask yourself how you'd
|
||||
add these functions.
|
||||
|
||||
In the framework we have sketched out for _Knave_, it would be simple - you'd add your race/class as
|
||||
an Attribute on your Character:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/characters.py
|
||||
|
||||
from evennia import DefaultCharacter, AttributeProperty
|
||||
# ...
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
|
||||
# ...
|
||||
|
||||
charclass = AttributeProperty("Fighter")
|
||||
charrace = AttributeProperty("Human")
|
||||
|
||||
```
|
||||
We use `charclass` rather than `class` here, because `class` is a reserved Python keyword. Naming
|
||||
`race` as `charrace` thus matches in style.
|
||||
|
||||
We'd then need to expand our [rules module](./Beginner-Tutorial-Rules.md) (and later
|
||||
[character generation](./Beginner-Tutorial-Chargen.md) to check and include what these classes mean.
|
||||
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
With the `EvAdventureCharacter` class in place, we have a better understanding of how our PCs will look
|
||||
like under _Knave_.
|
||||
|
||||
For now, we only have bits and pieces and haven't been testing this code in-game. But if you want
|
||||
you can swap yourself into `EvAdventureCharacter` right now. Log into your game and run
|
||||
the command
|
||||
|
||||
type self = evadventure.characters.EvAdventureCharacter
|
||||
|
||||
If all went well, `ex self` will now show your typeclass as being `EvAdventureCharacter`.
|
||||
Check out your strength with
|
||||
|
||||
py self.strength = 3
|
||||
|
||||
```{important}
|
||||
When doing `ex self` you will _not_ see all your Abilities listed yet. That's because
|
||||
Attributes added with `AttributeProperty` are not available until they have been accessed at
|
||||
least once. So once you set (or look at) `.strength` above, `strength` will show in `examine` from
|
||||
then on.
|
||||
```
|
||||
|
|
@ -1,711 +0,0 @@
|
|||
# Character Generation
|
||||
|
||||
In previous lessons we have established how a character looks. Now we need to give the player a
|
||||
chance to create one.
|
||||
|
||||
## How it will work
|
||||
|
||||
A fresh Evennia install will automatically create a new Character with the same name as your
|
||||
Account when you log in. This is quick and simple and mimics older MUD styles. You could picture
|
||||
doing this, and then customizing the Character in-place.
|
||||
|
||||
We will be a little more sophisticated though. We want the user to be able to create a character
|
||||
using a menu when they log in.
|
||||
|
||||
We do this by editing `mygame/server/conf/settings.py` and adding the line
|
||||
|
||||
AUTO_CREATE_CHARACTER_WITH_ACCOUNT = False
|
||||
|
||||
When doing this, connecting with the game with a new account will land you in "OOC" mode. The
|
||||
ooc-version of `look` (sitting in the Account cmdset) will show a list of available characters
|
||||
if you have any. You can also enter `charcreate` to make a new character. The `charcreate` is a
|
||||
simple command coming with Evennia that just lets you make a new character with a given name and
|
||||
description. We will later modify that to kick off our chargen. For now we'll just keep in mind
|
||||
that's how we'll start off the menu.
|
||||
|
||||
In _Knave_, most of the character-generation is random. This means this tutorial can be pretty
|
||||
compact while still showing the basic idea. What we will create is a menu looking like this:
|
||||
|
||||
|
||||
```
|
||||
Silas
|
||||
|
||||
STR +1
|
||||
DEX +2
|
||||
CON +1
|
||||
INT +3
|
||||
WIS +1
|
||||
CHA +2
|
||||
|
||||
You are lanky with a sunken face and filthy hair, breathy speech, and foreign clothing.
|
||||
You were a herbalist, but you were pursued and ended up a knave. You are honest but also
|
||||
suspicious. You are of the neutral alignment.
|
||||
|
||||
Your belongings:
|
||||
Brigandine armor, ration, ration, sword, torch, torch, torch, torch, torch,
|
||||
tinderbox, chisel, whistle
|
||||
|
||||
----------------------------------------------------------------------------------------
|
||||
1. Change your name
|
||||
2. Swap two of your ability scores (once)
|
||||
3. Accept and create character
|
||||
```
|
||||
|
||||
If you select 1, you get a new menu node:
|
||||
|
||||
```
|
||||
Your current name is Silas. Enter a new name or leave empty to abort.
|
||||
-----------------------------------------------------------------------------------------
|
||||
```
|
||||
You can now enter a new name. When pressing return you'll get back to the first menu node
|
||||
showing your character, now with the new name.
|
||||
|
||||
If you select 2, you go to another menu node:
|
||||
|
||||
```
|
||||
Your current abilities:
|
||||
|
||||
STR +1
|
||||
DEX +2
|
||||
CON +1
|
||||
INT +3
|
||||
WIS +1
|
||||
CHA +2
|
||||
|
||||
You can swap the values of two abilities around.
|
||||
You can only do this once, so choose carefully!
|
||||
|
||||
To swap the values of e.g. STR and INT, write 'STR INT'. Empty to abort.
|
||||
------------------------------------------------------------------------------------------
|
||||
```
|
||||
If you enter `WIS CHA` here, WIS will become `+2` and `CHA` `+1`. You will then again go back
|
||||
to the main node to see your new character, but this time the option to swap will no longer be
|
||||
available (you can only do it once).
|
||||
|
||||
If you finally select the `Accept and create character` option, the character will be created
|
||||
and you'll leave the menu;
|
||||
|
||||
Character was created!
|
||||
|
||||
## Random tables
|
||||
|
||||
```{sidebar}
|
||||
Full Knave random tables are found in
|
||||
[evennia/contrib/tutorials/evadventure/random_tables.py](../../../api/evennia.contrib.tutorials.evadventure.random_tables.md).
|
||||
```
|
||||
|
||||
> Make a new module `mygame/evadventure/random_tables.py`.
|
||||
|
||||
Since most of _Knave_'s character generation is random we will need to roll on random tables
|
||||
from the _Knave_ rulebook. While we added the ability to roll on a random table back in the
|
||||
[Rules Tutorial](./Beginner-Tutorial-Rules.md), we haven't added the relevant tables yet.
|
||||
|
||||
```
|
||||
# in mygame/evadventure/random_tables.py
|
||||
|
||||
chargen_tables = {
|
||||
"physique": [
|
||||
"athletic", "brawny", "corpulent", "delicate", "gaunt", "hulking", "lanky",
|
||||
"ripped", "rugged", "scrawny", "short", "sinewy", "slender", "flabby",
|
||||
"statuesque", "stout", "tiny", "towering", "willowy", "wiry",
|
||||
],
|
||||
"face": [
|
||||
"bloated", "blunt", "bony", # ...
|
||||
], # ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The tables are just copied from the _Knave_ rules. We group the aspects in a dict
|
||||
`character_generation` to separate chargen-only tables from other random tables we'll also
|
||||
keep in here.
|
||||
|
||||
## Storing state of the menu
|
||||
|
||||
```{sidebar}
|
||||
There is a full implementation of the chargen in
|
||||
[evennia/contrib/tutorials/evadventure/chargen.py](../../../api/evennia.contrib.tutorials.evadventure.chargen.md).
|
||||
```
|
||||
> create a new module `mygame/evadventure/chargen.py`.
|
||||
|
||||
During character generation we will need an entity to store/retain the changes, like a
|
||||
'temporary character sheet'.
|
||||
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
from .random_tables import chargen_tables
|
||||
from .rules import dice
|
||||
|
||||
class TemporaryCharacterSheet:
|
||||
|
||||
def _random_ability(self):
|
||||
return min(dice.roll("1d6"), dice.roll("1d6"), dice.roll("1d6"))
|
||||
|
||||
def __init__(self):
|
||||
self.ability_changes = 0 # how many times we tried swap abilities
|
||||
|
||||
# name will likely be modified later
|
||||
self.name = dice.roll_random_table("1d282", chargen_tables["name"])
|
||||
|
||||
# base attribute values
|
||||
self.strength = self._random_ability()
|
||||
self.dexterity = self._random_ability()
|
||||
self.constitution = self._random_ability()
|
||||
self.intelligence = self._random_ability()
|
||||
self.wisdom = self._random_ability()
|
||||
self.charisma = self._random_ability()
|
||||
|
||||
# physical attributes (only for rp purposes)
|
||||
physique = dice.roll_random_table("1d20", chargen_tables["physique"])
|
||||
face = dice.roll_random_table("1d20", chargen_tables["face"])
|
||||
skin = dice.roll_random_table("1d20", chargen_tables["skin"])
|
||||
hair = dice.roll_random_table("1d20", chargen_tables["hair"])
|
||||
clothing = dice.roll_random_table("1d20", chargen_tables["clothing"])
|
||||
speech = dice.roll_random_table("1d20", chargen_tables["speech"])
|
||||
virtue = dice.roll_random_table("1d20", chargen_tables["virtue"])
|
||||
vice = dice.roll_random_table("1d20", chargen_tables["vice"])
|
||||
background = dice.roll_random_table("1d20", chargen_tables["background"])
|
||||
misfortune = dice.roll_random_table("1d20", chargen_tables["misfortune"])
|
||||
alignment = dice.roll_random_table("1d20", chargen_tables["alignment"])
|
||||
|
||||
self.desc = (
|
||||
f"You are {physique} with a {face} face, {skin} skin, {hair} hair, {speech} speech,"
|
||||
f" and {clothing} clothing. You were a {background.title()}, but you were"
|
||||
f" {misfortune} and ended up a knave. You are {virtue} but also {vice}. You are of the"
|
||||
f" {alignment} alignment."
|
||||
)
|
||||
|
||||
#
|
||||
self.hp_max = max(5, dice.roll("1d8"))
|
||||
self.hp = self.hp_max
|
||||
self.xp = 0
|
||||
self.level = 1
|
||||
|
||||
# random equipment
|
||||
self.armor = dice.roll_random_table("1d20", chargen_tables["armor"])
|
||||
|
||||
_helmet_and_shield = dice.roll_random_table("1d20", chargen_tables["helmets and shields"])
|
||||
self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
|
||||
self.shield = "shield" if "shield" in _helmet_and_shield else "none"
|
||||
|
||||
self.weapon = dice.roll_random_table("1d20", chargen_tables["starting weapon"])
|
||||
|
||||
self.backpack = [
|
||||
"ration",
|
||||
"ration",
|
||||
dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
|
||||
dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
|
||||
dice.roll_random_table("1d20", chargen_tables["general gear 1"]),
|
||||
dice.roll_random_table("1d20", chargen_tables["general gear 2"]),
|
||||
]
|
||||
```
|
||||
|
||||
Here we have followed the _Knave_ rulebook to randomize abilities, description and equipment.
|
||||
The `dice.roll()` and `dice.roll_random_table` methods now become very useful! Everything here
|
||||
should be easy to follow.
|
||||
|
||||
The main difference from baseline _Knave_ is that we make a table of "starting weapon" (in Knave
|
||||
you can pick whatever you like).
|
||||
|
||||
We also initialize `.ability_changes = 0`. Knave only allows us to swap the values of two
|
||||
Abilities _once_. We will use this to know if it has been done or not.
|
||||
|
||||
### Showing the sheet
|
||||
|
||||
Now that we have our temporary character sheet, we should make it easy to visualize it.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
_TEMP_SHEET = """
|
||||
{name}
|
||||
|
||||
STR +{strength}
|
||||
DEX +{dexterity}
|
||||
CON +{constitution}
|
||||
INT +{intelligence}
|
||||
WIS +{wisdom}
|
||||
CHA +{charisma}
|
||||
|
||||
{description}
|
||||
|
||||
Your belongings:
|
||||
{equipment}
|
||||
"""
|
||||
|
||||
class TemporaryCharacterSheet:
|
||||
|
||||
# ...
|
||||
|
||||
def show_sheet(self):
|
||||
equipment = (
|
||||
str(item)
|
||||
for item in [self.armor, self.helmet, self.shield, self.weapon] + self.backpack
|
||||
if item
|
||||
)
|
||||
|
||||
return _TEMP_SHEET.format(
|
||||
name=self.name,
|
||||
strength=self.strength,
|
||||
dexterity=self.dexterity,
|
||||
constitution=self.constitution,
|
||||
intelligence=self.intelligence,
|
||||
wisdom=self.wisdom,
|
||||
charisma=self.charisma,
|
||||
description=self.desc,
|
||||
equipment=", ".join(equipment),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
The new `show_sheet` method collect the data from the temporary sheet and return it in a pretty
|
||||
form. Making a 'template' string like `_TEMP_SHEET` makes it easier to change things later if you want
|
||||
to change how things look.
|
||||
|
||||
### Apply character
|
||||
|
||||
Once we are happy with our character, we need to actually create it with the stats we chose.
|
||||
This is a bit more involved.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
# ...
|
||||
|
||||
from .characters import EvAdventureCharacter
|
||||
from evennia import create_object
|
||||
from evennia.prototypes.spawner import spawn
|
||||
|
||||
|
||||
class TemporaryCharacterSheet:
|
||||
|
||||
# ...
|
||||
|
||||
def apply(self):
|
||||
# create character object with given abilities
|
||||
new_character = create_object(
|
||||
EvAdventureCharacter,
|
||||
key=self.name,
|
||||
attrs=(
|
||||
("strength", self.strength),
|
||||
("dexterity", self.dexterity),
|
||||
("constitution", self.constitution),
|
||||
("intelligence", self.intelligence),
|
||||
("wisdom", self.wisdom),
|
||||
("charisma", self.wisdom),
|
||||
("hp", self.hp),
|
||||
("hp_max", self.hp_max),
|
||||
("desc", self.desc),
|
||||
),
|
||||
)
|
||||
# spawn equipment (will require prototypes created before it works)
|
||||
if self.weapon:
|
||||
weapon = spawn(self.weapon)
|
||||
new_character.equipment.move(weapon)
|
||||
if self.shield:
|
||||
shield = spawn(self.shield)
|
||||
new_character.equipment.move(shield)
|
||||
if self.armor:
|
||||
armor = spawn(self.armor)
|
||||
new_character.equipment.move(armor)
|
||||
if self.helmet:
|
||||
helmet = spawn(self.helmet)
|
||||
new_character.equipment.move(helmet)
|
||||
|
||||
for item in self.backpack:
|
||||
item = spawn(item)
|
||||
new_character.equipment.store(item)
|
||||
|
||||
return new_character
|
||||
```
|
||||
|
||||
We use `create_object` to create a new `EvAdventureCharacter`. We feed it with all relevant data
|
||||
from the temporary character sheet. This is when these become an actual character.
|
||||
|
||||
```{sidebar}
|
||||
A prototype is basically a `dict` describing how the object should be created. Since
|
||||
it's just a piece of code, it can stored in a Python module and used to quickly _spawn_ (create)
|
||||
things from those prototypes.
|
||||
```
|
||||
|
||||
Each piece of equipment is an object in in its own right. We will here assume that all game
|
||||
items are defined as [Prototypes](../../../Components/Prototypes.md) keyed to its name, such as "sword", "brigandine
|
||||
armor" etc.
|
||||
|
||||
We haven't actually created those prototypes yet, so for now we'll need to assume they are there.
|
||||
Once a piece of equipment has been spawned, we make sure to move it into the `EquipmentHandler` we
|
||||
created in the [Equipment lesson](./Beginner-Tutorial-Equipment.md).
|
||||
|
||||
|
||||
## Initializing EvMenu
|
||||
|
||||
Evennia comes with a full menu-generation system based on [Command sets](../../../Components/Command-Sets.md), called
|
||||
[EvMenu](../../../Components/EvMenu.md).
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
from evennia import EvMenu
|
||||
|
||||
# ...
|
||||
|
||||
# chargen menu
|
||||
|
||||
|
||||
# this goes to the bottom of the module
|
||||
|
||||
def start_chargen(caller, session=None):
|
||||
"""
|
||||
This is a start point for spinning up the chargen from a command later.
|
||||
|
||||
"""
|
||||
|
||||
menutree = {} # TODO!
|
||||
|
||||
# this generates all random components of the character
|
||||
tmp_character = TemporaryCharacterSheet()
|
||||
|
||||
EvMenu(caller, menutree, session=session, tmp_character=tmp_character)
|
||||
|
||||
```
|
||||
|
||||
This first function is what we will call from elsewhere (for example from a custom `charcreate`
|
||||
command) to kick the menu into gear.
|
||||
|
||||
It takes the `caller` (the one to want to start the menu) and a `session` argument. The latter will help
|
||||
track just which client-connection we are using (depending on Evennia settings, you could be
|
||||
connecting with multiple clients).
|
||||
|
||||
We create a `TemporaryCharacterSheet` and call `.generate()` to make a random character. We then
|
||||
feed all this into `EvMenu`.
|
||||
|
||||
The moment this happens, the user will be in the menu, there are no further steps needed.
|
||||
|
||||
The `menutree` is what we'll create next. It describes which menu 'nodes' are available to jump
|
||||
between.
|
||||
|
||||
## Main Node: Choosing what to do
|
||||
|
||||
This is the first menu node. It will act as a central hub, from which one can choose different
|
||||
actions.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
# ...
|
||||
|
||||
# at the end of the module, but before the `start_chargen` function
|
||||
|
||||
def node_chargen(caller, raw_string, **kwargs):
|
||||
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
|
||||
text = tmp_character.show_sheet()
|
||||
|
||||
options = [
|
||||
{
|
||||
"desc": "Change your name",
|
||||
"goto": ("node_change_name", kwargs)
|
||||
}
|
||||
]
|
||||
if tmp_character.ability_changes <= 0:
|
||||
options.append(
|
||||
{
|
||||
"desc": "Swap two of your ability scores (once)",
|
||||
"goto": ("node_swap_abilities", kwargs),
|
||||
}
|
||||
)
|
||||
options.append(
|
||||
{
|
||||
"desc": "Accept and create character",
|
||||
"goto": ("node_apply_character", kwargs)
|
||||
},
|
||||
)
|
||||
|
||||
return text, options
|
||||
|
||||
# ...
|
||||
```
|
||||
|
||||
A lot to unpack here! In Evennia, it's convention to name your node-functions `node_*`. While
|
||||
not required, it helps you track what is a node and not.
|
||||
|
||||
Every menu-node, should accept `caller, raw_string, **kwargs` as arguments. Here `caller` is the
|
||||
`caller` you passed into the `EvMenu` call. `raw_string` is the input given by the user in order
|
||||
to _get to this node_, so currently empty. The `**kwargs` are all extra keyword arguments passed
|
||||
into `EvMenu`. They can also be passed between nodes. In this case, we passed the
|
||||
keyword `tmp_character` to `EvMenu`. We now have the temporary character sheet available in the
|
||||
node!
|
||||
|
||||
An `EvMenu` node must always return two things - `text` and `options`. The `text` is what will
|
||||
show to the user when looking at this node. The `options` are, well, what options should be
|
||||
presented to move on from here to some other place.
|
||||
|
||||
For the text, we simply get a pretty-print of the temporary character sheet. A single option is
|
||||
defined as a `dict` like this:
|
||||
|
||||
```python
|
||||
{
|
||||
"key": ("name". "alias1", "alias2", ...), # if skipped, auto-show a number
|
||||
"desc": "text to describe what happens when selecting option",.
|
||||
"goto": ("name of node or a callable", kwargs_to_pass_into_next_node_or_callable)
|
||||
}
|
||||
```
|
||||
|
||||
Multiple option-dicts are returned in a list or tuple. The `goto` option-key is important to
|
||||
understand. The job of this is to either point directly to another node (by giving its name), or
|
||||
by pointing to a Python callable (like a function) _that then returns that name_. You can also
|
||||
pass kwargs (as a dict). This will be made available as `**kwargs` in the callable or next node.
|
||||
|
||||
While an option can have a `key`, you can also skip it to just get a running number.
|
||||
|
||||
In our `node_chargen` node, we point to three nodes by name: `node_change_name`,
|
||||
`node_swap_abilities`, and `node_apply_character`. We also make sure to pass along `kwargs`
|
||||
to each node, since that contains our temporary character sheet.
|
||||
|
||||
The middle of these options only appear if we haven't already switched two abilities around - to
|
||||
know this, we check the `.ability_changes` property to make sure it's still 0.
|
||||
|
||||
|
||||
## Node: Changing your name
|
||||
|
||||
This is where you end up if you opted to change your name in `node_chargen`.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
# ...
|
||||
|
||||
# after previous node
|
||||
|
||||
def _update_name(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Used by node_change_name below to check what user
|
||||
entered and update the name if appropriate.
|
||||
|
||||
"""
|
||||
if raw_string:
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
tmp_character.name = raw_string.lower().capitalize()
|
||||
|
||||
return "node_chargen", kwargs
|
||||
|
||||
|
||||
def node_change_name(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Change the random name of the character.
|
||||
|
||||
"""
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
|
||||
text = (
|
||||
f"Your current name is |w{tmp_character.name}|n. "
|
||||
"Enter a new name or leave empty to abort."
|
||||
)
|
||||
|
||||
options = {
|
||||
"key": "_default",
|
||||
"goto": (_update_name, kwargs)
|
||||
}
|
||||
|
||||
return text, options
|
||||
```
|
||||
|
||||
There are two functions here - the menu node itself (`node_change_name`) and a
|
||||
helper _goto_function_ (`_update_name`) to handle the user's input.
|
||||
|
||||
For the (single) option, we use a special `key` named `_default`. This makes this option
|
||||
a catch-all: If the user enters something that does not match any other option, this is
|
||||
the option that will be used.
|
||||
Since we have no other options here, we will always use this option no matter what the user enters.
|
||||
|
||||
Also note that the `goto` part of the option points to the `_update_name` callable rather than to
|
||||
the name of a node. It's important we keep passing `kwargs` along to it!
|
||||
|
||||
When a user writes anything at this node, the `_update_name` callable will be called. This has
|
||||
the same arguments as a node, but it is _not_ a node - we will only use it to _figure out_ which
|
||||
node to go to next.
|
||||
|
||||
In `_update_name` we now have a use for the `raw_string` argument - this is what was written by
|
||||
the user on the previous node, remember? This is now either an empty string (meaning to ignore
|
||||
it) or the new name of the character.
|
||||
|
||||
A goto-function like `_update_name` must return the name of the next node to use. It can also
|
||||
optionally return the `kwargs` to pass into that node - we want to always do this, so we don't
|
||||
loose our temporary character sheet. Here we will always go back to the `node_chargen`.
|
||||
|
||||
> Hint: If returning `None` from a goto-callable, you will always return to the last node you
|
||||
> were at.
|
||||
|
||||
## Node: Swapping Abilities around
|
||||
|
||||
You get here by selecting the second option from the `node_chargen` node.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/chargen.py
|
||||
|
||||
# ...
|
||||
|
||||
# after previous node
|
||||
|
||||
_ABILITIES = {
|
||||
"STR": "strength",
|
||||
"DEX": "dexterity",
|
||||
"CON": "constitution",
|
||||
"INT": "intelligence",
|
||||
"WIS": "wisdom",
|
||||
"CHA": "charisma",
|
||||
}
|
||||
|
||||
|
||||
def _swap_abilities(caller, raw_string, **kwargs):
|
||||
"""
|
||||
Used by node_swap_abilities to parse the user's input and swap ability
|
||||
values.
|
||||
|
||||
"""
|
||||
if raw_string:
|
||||
abi1, *abi2 = raw_string.split(" ", 1)
|
||||
if not abi2:
|
||||
caller.msg("That doesn't look right.")
|
||||
return None, kwargs
|
||||
abi2 = abi2[0]
|
||||
abi1, abi2 = abi1.upper().strip(), abi2.upper().strip()
|
||||
if abi1 not in _ABILITIES or abi2 not in _ABILITIES:
|
||||
caller.msg("Not a familiar set of abilites.")
|
||||
return None, kwargs
|
||||
|
||||
# looks okay = swap values. We need to convert STR to strength etc
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
abi1 = _ABILITIES[abi1]
|
||||
abi2 = _ABILITIES[abi2]
|
||||
abival1 = getattr(tmp_character, abi1)
|
||||
abival2 = getattr(tmp_character, abi2)
|
||||
|
||||
setattr(tmp_character, abi1, abival2)
|
||||
setattr(tmp_character, abi2, abival1)
|
||||
|
||||
tmp_character.ability_changes += 1
|
||||
|
||||
return "node_chargen", kwargs
|
||||
|
||||
|
||||
def node_swap_abilities(caller, raw_string, **kwargs):
|
||||
"""
|
||||
One is allowed to swap the values of two abilities around, once.
|
||||
|
||||
"""
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
|
||||
text = f"""
|
||||
Your current abilities:
|
||||
|
||||
STR +{tmp_character.strength}
|
||||
DEX +{tmp_character.dexterity}
|
||||
CON +{tmp_character.constitution}
|
||||
INT +{tmp_character.intelligence}
|
||||
WIS +{tmp_character.wisdom}
|
||||
CHA +{tmp_character.charisma}
|
||||
|
||||
You can swap the values of two abilities around.
|
||||
You can only do this once, so choose carefully!
|
||||
|
||||
To swap the values of e.g. STR and INT, write |wSTR INT|n. Empty to abort.
|
||||
"""
|
||||
|
||||
options = {"key": "_default", "goto": (_swap_abilities, kwargs)}
|
||||
|
||||
return text, options
|
||||
```
|
||||
|
||||
This is more code, but the logic is the same - we have a node (`node_swap_abilities`) and
|
||||
and a goto-callable helper (`_swap_abilities`). We catch everything the user writes on the
|
||||
node (such as `WIS CON`) and feed it into the helper.
|
||||
|
||||
In `_swap_abilities`, we need to analyze the `raw_string` from the user to see what they
|
||||
want to do.
|
||||
|
||||
Most code in the helper is validating the user didn't enter nonsense. If they did,
|
||||
we use `caller.msg()` to tell them and then return `None, kwargs`, which re-runs the same node (the
|
||||
name-selection) all over again.
|
||||
|
||||
Since we want users to be able to write "CON" instead of the longer "constitution", we need a
|
||||
mapping `_ABILITIES` to easily convert between the two (it's stored as `consitution` on the
|
||||
temporary character sheet). Once we know which abilities they want to swap, we do so and tick up
|
||||
the `.ability_changes` counter. This means this option will no longer be available from the main
|
||||
node.
|
||||
|
||||
Finally, we return to `node_chargen` again.
|
||||
|
||||
## Node: Creating the Character
|
||||
|
||||
We get here from the main node by opting to finish chargen.
|
||||
|
||||
```python
|
||||
node_apply_character(caller, raw_string, **kwargs):
|
||||
"""
|
||||
End chargen and create the character. We will also puppet it.
|
||||
|
||||
"""
|
||||
tmp_character = kwargs["tmp_character"]
|
||||
new_character = tmp_character.apply(caller)
|
||||
|
||||
caller.account.db._playable_characters = [new_character]
|
||||
|
||||
text = "Character created!"
|
||||
|
||||
return text, None
|
||||
```
|
||||
When entering the node, we will take the Temporary character sheet and use its `.appy` method to
|
||||
create a new Character with all equipment.
|
||||
|
||||
This is what is called an _end node_, because it returns `None` instead of options. After this,
|
||||
the menu will exit. We will be back to the default character selection screen. The characters
|
||||
found on that screen are the ones listed in the `_playable_characters` Attribute, so we need to
|
||||
also the new character to it.
|
||||
|
||||
|
||||
## Tying the nodes together
|
||||
|
||||
```python
|
||||
def start_chargen(caller, session=None):
|
||||
"""
|
||||
This is a start point for spinning up the chargen from a command later.
|
||||
|
||||
"""
|
||||
menutree = { # <----- can now add this!
|
||||
"node_chargen": node_chargen,
|
||||
"node_change_name": node_change_name,
|
||||
"node_swap_abilities": node_swap_abilities,
|
||||
"node_apply_character": node_apply_character
|
||||
}
|
||||
|
||||
# this generates all random components of the character
|
||||
tmp_character = TemporaryCharacterSheet()
|
||||
tmp_character.generate()
|
||||
|
||||
EvMenu(caller, menutree, session=session,
|
||||
startnode="node_chargen", # <-----
|
||||
tmp_character=tmp_character)
|
||||
|
||||
```
|
||||
|
||||
Now that we have all the nodes, we add them to the `menutree` we left empty before. We only add
|
||||
the nodes, _not_ the goto-helpers! The keys we set in the `menutree` dictionary are the names we
|
||||
should use to point to nodes from inside the menu (and we did).
|
||||
|
||||
We also add a keyword argument `startnode` pointing to the `node_chargen` node. This tells EvMenu
|
||||
to first jump into that node when the menu is starting up.
|
||||
|
||||
## Conclusions
|
||||
|
||||
This lesson taught us how to use `EvMenu` to make an interactive character generator. In an RPG
|
||||
more complex than _Knave_, the menu would be bigger and more intricate, but the same principles
|
||||
apply.
|
||||
|
||||
Together with the previous lessons we have now fished most of the basics around player
|
||||
characters - how they store their stats, handle their equipment and how to create them.
|
||||
|
||||
In the next lesson we'll address how EvAdventure _Rooms_ work.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# In-game Commands
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Dynamically generated Dungeon
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,599 +0,0 @@
|
|||
# Handling Equipment
|
||||
|
||||
In _Knave_, you have a certain number of inventory "slots". The amount of slots is given by `CON + 10`.
|
||||
All items (except coins) have a `size`, indicating how many slots it uses. You can't carry more items
|
||||
than you have slot-space for. Also items wielded or worn count towards the slots.
|
||||
|
||||
We still need to track what the character is using however: What weapon they have readied affects the damage
|
||||
they can do. The shield, helmet and armor they use affects their defense.
|
||||
|
||||
We have already set up the possible 'wear/wield locations' when we defined our Objects
|
||||
[in the previous lesson](./Beginner-Tutorial-Objects.md). This is what we have in `enums.py`:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/enums.py
|
||||
|
||||
# ...
|
||||
|
||||
class WieldLocation(Enum):
|
||||
|
||||
BACKPACK = "backpack"
|
||||
WEAPON_HAND = "weapon_hand"
|
||||
SHIELD_HAND = "shield_hand"
|
||||
TWO_HANDS = "two_handed_weapons"
|
||||
BODY = "body" # armor
|
||||
HEAD = "head" # helmets
|
||||
```
|
||||
|
||||
Basically, all the weapon/armor locations are exclusive - you can only have one item in each (or none).
|
||||
The BACKPACK is special - it contains any number of items (up to the maximum slot usage).
|
||||
|
||||
## EquipmentHandler that saves
|
||||
|
||||
> Create a new module `mygame/evadventure/equipment.py`.
|
||||
|
||||
```{sidebar}
|
||||
If you want to understand more about behind how Evennia uses handlers, there is a
|
||||
[dedicated tutorial](../../Tutorial-Persistent-Handler.md) talking about the principle.
|
||||
```
|
||||
In default Evennia, everything you pick up will end up "inside" your character object (that is, have
|
||||
you as its `.location`). This is called your _inventory_ and has no limit. We will keep 'moving items into us'
|
||||
when we pick them up, but we will add more functionality using an _Equipment handler_.
|
||||
|
||||
A handler is (for our purposes) an object that sits "on" another entity, containing functionality
|
||||
for doing one specific thing (managing equipment, in our case).
|
||||
|
||||
This is the start of our handler:
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/equipment.py
|
||||
|
||||
from .enums import WieldLocation
|
||||
|
||||
class EquipmentHandler:
|
||||
save_attribute = "inventory_slots"
|
||||
|
||||
def __init__(self, obj):
|
||||
# here obj is the character we store the handler on
|
||||
self.obj = obj
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load our data from an Attribute on `self.obj`"""
|
||||
self.slots = self.obj.attributes.get(
|
||||
self.save_attribute,
|
||||
category="inventory",
|
||||
default={
|
||||
WieldLocation.WEAPON_HAND: None,
|
||||
WieldLocation.SHIELD_HAND: None,
|
||||
WieldLocation.TWO_HANDS: None,
|
||||
WieldLocation.BODY: None,
|
||||
WieldLocation.HEAD: None,
|
||||
WieldLocation.BACKPACK: []
|
||||
}
|
||||
)
|
||||
|
||||
def _save(self):
|
||||
"""Save our data back to the same Attribute"""
|
||||
self.obj.attributes.add(self.save_attribute, self.slots, category="inventory")
|
||||
```
|
||||
|
||||
This is a compact and functional little handler. Before analyzing how it works, this is how
|
||||
we will add it to the Character:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/characters.py
|
||||
|
||||
# ...
|
||||
|
||||
from evennia.utils.utils import lazy_property
|
||||
from .equipment import EquipmentHandler
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
|
||||
# ...
|
||||
|
||||
@lazy_property
|
||||
def equipment(self):
|
||||
return EquipmentHandler(self)
|
||||
```
|
||||
|
||||
After reloading the server, the equipment-handler will now be accessible on character-instances as
|
||||
|
||||
character.equipment
|
||||
|
||||
The `@lazy_property` works such that it will not load the handler until someone actually tries to
|
||||
fetch it with `character.equipment`. When that
|
||||
happens, we start up the handler and feed it `self` (the `Character` instance itself). This is what
|
||||
enters `__init__` as `.obj` in the `EquipmentHandler` code above.
|
||||
|
||||
So we now have a handler on the character, and the handler has a back-reference to the character it sits
|
||||
on.
|
||||
|
||||
Since the handler itself is just a regular Python object, we need to use the `Character` to store
|
||||
our data - our _Knave_ "slots". We must save them to the database, because we want the server to remember
|
||||
them even after reloading.
|
||||
|
||||
Using `self.obj.attributes.add()` and `.get()` we save the data to the Character in a specially named
|
||||
[Attribute](../../../Components/Attributes.md). Since we use a `category`, we are unlikely to collide with
|
||||
other Attributes.
|
||||
|
||||
Our storage structure is a `dict` with keys after our available `WieldLocation` enums. Each can only
|
||||
have one item except `WieldLocation.BACKPACK`, which is a list.
|
||||
|
||||
## Connecting the EquipmentHandler
|
||||
|
||||
Whenever an object leaves from one location to the next, Evennia will call a set of _hooks_ (methods) on the
|
||||
object that moves, on the source-location and on its destination. This is the same for all moving things -
|
||||
whether it's a character moving between rooms or an item being dropping from your hand to the ground.
|
||||
|
||||
We need to tie our new `EquipmentHandler` into this system. By reading the doc page on [Objects](../../../Components/Objects.md),
|
||||
or looking at the [DefaultObject.move_to](evennia.objects.objects.DefaultObject.move_to) docstring, we'll
|
||||
find out what hooks Evennia will call. Here `self` is the object being moved from
|
||||
`source_location` to `destination`:
|
||||
|
||||
|
||||
1. `self.at_pre_move(destination)` (abort if return False)
|
||||
2. `source_location.at_pre_object_leave(self, destination)` (abort if return False)
|
||||
3. `destination.at_pre_object_receive(self, source_location)` (abort if return False)
|
||||
4. `source_location.at_object_leave(self, destination)`
|
||||
5. `self.announce_move_from(destination)`
|
||||
6. (move happens here)
|
||||
7. `self.announce_move_to(source_location)`
|
||||
8. `destination.at_object_receive(self, source_location)`
|
||||
9. `self.at_post_move(source_location)`
|
||||
|
||||
All of these hooks can be overridden to customize movement behavior. In this case we are interested in
|
||||
controlling how items 'enter' and 'leave' our character - being 'inside' the character is the same as
|
||||
them 'carrying' it. We have three good hook-candidates to use for this.
|
||||
|
||||
- `.at_pre_object_receive` - used to check if you can actually pick something up, or if your equipment-store is full.
|
||||
- `.at_object_receive` - used to add the item to the equipmenthandler
|
||||
- `.at_object_leave` - used to remove the item from the equipmenthandler
|
||||
|
||||
You could also picture using `.at_pre_object_leave` to restrict dropping (cursed?) items, but
|
||||
we will skip that for this tutorial.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/character.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
|
||||
# ...
|
||||
|
||||
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
|
||||
"""Called by Evennia before object arrives 'in' this character (that is,
|
||||
if they pick up something). If it returns False, move is aborted.
|
||||
|
||||
"""
|
||||
return self.equipment.validate_slot_usage(moved_object)
|
||||
|
||||
def at_object_receive(self, moved_object, source_location, **kwargs):
|
||||
"""
|
||||
Called by Evennia when an object arrives 'in' the character.
|
||||
|
||||
"""
|
||||
self.equipment.add(moved_object)
|
||||
|
||||
def at_object_leave(self, moved_object, destination, **kwargs):
|
||||
"""
|
||||
Called by Evennia when object leaves the Character.
|
||||
|
||||
"""
|
||||
self.equipment.remove(moved_object)
|
||||
```
|
||||
|
||||
Above we have assumed the `EquipmentHandler` (`.equipment`) has methods `.validate_slot_usage`,
|
||||
`.add` and `.remove`. But we haven't actually added them yet - we just put some reasonable names! Before
|
||||
we can use this, we need to go actually adding those methods.
|
||||
|
||||
## Expanding the Equipmenthandler
|
||||
|
||||
## `.validate_slot_usage`
|
||||
|
||||
Let's start with implementing the first method we came up with above, `validate_slot_usage`:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/equipment.py
|
||||
|
||||
from .enums import WieldLocation, Ability
|
||||
|
||||
class EquipmentError(TypeError):
|
||||
"""All types of equipment-errors"""
|
||||
pass
|
||||
|
||||
class EquipmentHandler:
|
||||
|
||||
# ...
|
||||
|
||||
@property
|
||||
def max_slots(self):
|
||||
"""Max amount of slots, based on CON defense (CON + 10)"""
|
||||
return getattr(self.obj, Ability.CON.value, 1) + 10
|
||||
|
||||
def count_slots(self):
|
||||
"""Count current slot usage"""
|
||||
slots = self.slots
|
||||
wield_usage = sum(
|
||||
getattr(slotobj, "size", 0) or 0
|
||||
for slot, slotobj in slots.items()
|
||||
if slot is not WieldLocation.BACKPACK
|
||||
)
|
||||
backpack_usage = sum(
|
||||
getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK]
|
||||
)
|
||||
return wield_usage + backpack_usage
|
||||
|
||||
def validate_slot_usage(self, obj):
|
||||
"""
|
||||
Check if obj can fit in equipment, based on its size.
|
||||
|
||||
"""
|
||||
if not inherits_from(obj, EvAdventureObject):
|
||||
# in case we mix with non-evadventure objects
|
||||
raise EquipmentError(f"{obj.key} is not something that can be equipped.")
|
||||
|
||||
size = obj.size
|
||||
max_slots = self.max_slots
|
||||
current_slot_usage = self.count_slots()
|
||||
return current_slot_usage + size <= max_slots:
|
||||
|
||||
```
|
||||
|
||||
```{sidebar}
|
||||
The `@property` decorator turns a method into a property so you don't need to 'call' it.
|
||||
That is, you can access `.max_slots` instead of `.max_slots()`. In this case, it's just a
|
||||
little less to type.
|
||||
```
|
||||
We add two helpers - the `max_slots` _property_ and `count_slots`, a method that calculate the current
|
||||
slots being in use. Let's figure out how they work.
|
||||
|
||||
### `.max_slots`
|
||||
|
||||
For `max_slots`, remember that `.obj` on the handler is a back-reference to the `EvAdventureCharacter` we
|
||||
put this handler on. `getattr` is a Python method for retrieving a named property on an object.
|
||||
The `Enum` `Ability.CON.value` is the string `Constitution` (check out the
|
||||
[first Utility and Enums tutorial](./Beginner-Tutorial-Utilities.md) if you don't recall).
|
||||
|
||||
So to be clear,
|
||||
|
||||
```python
|
||||
getattr(self.obj, Ability.CON.value) + 10
|
||||
```
|
||||
is the same as writing
|
||||
|
||||
```python
|
||||
getattr(your_character, "Constitution") + 10
|
||||
```
|
||||
|
||||
which is the same as doing something like this:
|
||||
|
||||
```python
|
||||
your_character.Constitution + 10
|
||||
```
|
||||
|
||||
In our code we write `getattr(self.obj, Ability.CON.value, 1)` - that extra `1` means that if there
|
||||
should happen to _not_ be a property "Constitution" on `self.obj`, we should not error out but just
|
||||
return 1.
|
||||
|
||||
|
||||
### `.count_slots`
|
||||
|
||||
In this helper we use two Python tools - the `sum()` function and a
|
||||
[list comprehension](https://www.w3schools.com/python/python_lists_comprehension.asp). The former
|
||||
simply adds the values of any iterable together. The latter is a more efficient way to create a list:
|
||||
|
||||
new_list = [item for item in some_iterable if condition]
|
||||
all_above_5 = [num for num in range(10) if num > 5] # [6, 7, 8, 9]
|
||||
all_below_5 = [num for num in range(10) if num < 5] # [0, 1, 2, 3, 4]
|
||||
|
||||
To make it easier to understand, try reading the last line above as "for every number in the range 0-9,
|
||||
pick all with a value below 5 and make a list of them". You can also embed such comprehensions
|
||||
directly in a function call like `sum()` without using `[]` around it.
|
||||
|
||||
In `count_slots` we have this code:
|
||||
|
||||
```python
|
||||
wield_usage = sum(
|
||||
getattr(slotobj, "size", 0)
|
||||
for slot, slotobj in slots.items()
|
||||
if slot is not WieldLocation.BACKPACK
|
||||
)
|
||||
```
|
||||
|
||||
We should be able to follow all except `slots.items()`. Since `slots` is a `dict`, we can use `.items()`
|
||||
to get a sequence of `(key, value)` pairs. We store these in `slot` and `slotobj`. So the above can
|
||||
be understood as "for every `slot` and `slotobj`-pair in `slots`, check which slot location it is.
|
||||
If it is _not_ in the backpack, get its size and add it to the list. Sum over all these
|
||||
sizes".
|
||||
|
||||
A less compact but maybe more readonable way to write this would be:
|
||||
|
||||
```python
|
||||
backpack_item_sizes = []
|
||||
for slot, slotobj in slots.items():
|
||||
if slot is not WieldLocation.BACKPACK:
|
||||
size = getattr(slotobj, "size", 0)
|
||||
backpack_item_sizes.append(size)
|
||||
wield_usage = sum(backpack_item_sizes)
|
||||
```
|
||||
|
||||
The same is done for the items actually in the BACKPACK slot. The total sizes are added
|
||||
together.
|
||||
|
||||
### Validating slots
|
||||
|
||||
With these helpers in place, `validate_slot_usage` now becomes simple. We use `max_slots` to see how much we can carry.
|
||||
We then get how many slots we are already using (with `count_slots`) and see if our new `obj`'s size
|
||||
would be too much for us.
|
||||
|
||||
## `.add` and `.remove`
|
||||
|
||||
We will make it so `.add` puts something in the `BACKPACK` location and `remove` drops it, wherever
|
||||
it is (even if it was in your hands).
|
||||
|
||||
```python
|
||||
# mygame/evadventure/equipment.py
|
||||
|
||||
from .enums import WieldLocation, Ability
|
||||
|
||||
# ...
|
||||
|
||||
class EquipmentHandler:
|
||||
|
||||
# ...
|
||||
|
||||
def add(self, obj):
|
||||
"""
|
||||
Put something in the backpack.
|
||||
"""
|
||||
self.validate_slot_usage(obj)
|
||||
self.slots[WieldLocation.BACKPACK].append(obj)
|
||||
self._save()
|
||||
|
||||
def remove(self, slot):
|
||||
"""
|
||||
Remove contents of a particular slot, for
|
||||
example `equipment.remove(WieldLocation.SHIELD_HAND)`
|
||||
"""
|
||||
slots = self.slots
|
||||
ret = []
|
||||
if slot is WieldLocation.BACKPACK:
|
||||
# empty entire backpack!
|
||||
ret.extend(slots[slot])
|
||||
slots[slot] = []
|
||||
else:
|
||||
ret.append(slots[slot])
|
||||
slots[slot] = None
|
||||
if ret:
|
||||
self._save()
|
||||
return ret
|
||||
```
|
||||
|
||||
Both of these should be straight forward to follow. In `.add`, we make use of `validate_slot_usage` to
|
||||
double-check we can actually fit the thing, then we add the item to the backpack.
|
||||
|
||||
In `.delete`, we allow emptying by `WieldLocation` - we figure out what slot it is and return
|
||||
the item within (if any). If we gave `BACKPACK` as the slot, we empty the backpack and
|
||||
return all items.
|
||||
|
||||
Whenever we change the equipment loadout we must make sure to `._save()` the result, or it will
|
||||
be lost after a server reload.
|
||||
|
||||
## Moving things around
|
||||
|
||||
With the help of `.remove()` and `.add()` we can get things in and out of the `BACKPACK` equipment
|
||||
location. We also need to grab stuff from the backpack and wield or wear it. We add a `.move` method
|
||||
on the `EquipmentHandler` to do this:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/equipment.py
|
||||
|
||||
from .enums import WieldLocation, Ability
|
||||
|
||||
# ...
|
||||
|
||||
class EquipmentHandler:
|
||||
|
||||
# ...
|
||||
|
||||
def move(self, obj):
|
||||
"""Move object from backpack to its intended `inventory_use_slot`."""
|
||||
|
||||
# make sure to remove from equipment/backpack first, to avoid double-adding
|
||||
self.remove(obj)
|
||||
|
||||
slots = self.slots
|
||||
use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)
|
||||
|
||||
to_backpack = []
|
||||
if use_slot is WieldLocation.TWO_HANDS:
|
||||
# two-handed weapons can't co-exist with weapon/shield-hand used items
|
||||
to_backpack = [slots[WieldLocation.WEAPON_HAND], slots[WieldLocation.SHIELD_HAND]]
|
||||
slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
|
||||
slots[use_slot] = obj
|
||||
elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
|
||||
# can't keep a two-handed weapon if adding a one-handed weapon or shield
|
||||
to_backpack = [slots[WieldLocation.TWO_HANDS]]
|
||||
slots[WieldLocation.TWO_HANDS] = None
|
||||
slots[use_slot] = obj
|
||||
elif use_slot is WieldLocation.BACKPACK:
|
||||
# it belongs in backpack, so goes back to it
|
||||
to_backpack = [obj]
|
||||
else:
|
||||
# for others (body, head), just replace whatever's there
|
||||
replaced = [obj]
|
||||
slots[use_slot] = obj
|
||||
|
||||
for to_backpack_obj in to_backpack:
|
||||
# put stuff in backpack
|
||||
slots[use_slot].append(to_backpack_obj)
|
||||
|
||||
# store new state
|
||||
self._save()
|
||||
```
|
||||
|
||||
Here we remember that every `EvAdventureObject` has an `inventory_use_slot` property that tells us where
|
||||
it goes. So we just need to move the object to that slot, replacing whatever is in that place
|
||||
from before. Anything we replace goes back to the backpack.
|
||||
|
||||
## Get everything
|
||||
|
||||
In order to visualize our inventory, we need some method to get everything we are carrying.
|
||||
|
||||
|
||||
```python
|
||||
# mygame/evadventure/equipment.py
|
||||
|
||||
from .enums import WieldLocation, Ability
|
||||
|
||||
# ...
|
||||
|
||||
class EquipmentHandler:
|
||||
|
||||
# ...
|
||||
|
||||
def all(self):
|
||||
"""
|
||||
Get all objects in inventory, regardless of location.
|
||||
"""
|
||||
slots = self.slots
|
||||
lst = [
|
||||
(slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND),
|
||||
(slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND),
|
||||
(slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS),
|
||||
(slots[WieldLocation.BODY], WieldLocation.BODY),
|
||||
(slots[WieldLocation.HEAD], WieldLocation.HEAD),
|
||||
] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]]
|
||||
return lst
|
||||
```
|
||||
|
||||
Here we get all the equipment locations and add their contents together into a list of tuples
|
||||
`[(item, WieldLocation), ...]`. This is convenient for display.
|
||||
|
||||
## Weapon and armor
|
||||
|
||||
It's convenient to have the `EquipmentHandler` easily tell you what weapon is currently wielded
|
||||
and what _armor_ level all worn equipment provides. Otherwise you'd need to figure out what item is
|
||||
in which wield-slot and to add up armor slots manually every time you need to know.
|
||||
|
||||
|
||||
```python
|
||||
# mygame/evadventure/equipment.py
|
||||
|
||||
from .objects import WeaponEmptyHand
|
||||
from .enums import WieldLocation, Ability
|
||||
|
||||
# ...
|
||||
|
||||
class EquipmentHandler:
|
||||
|
||||
# ...
|
||||
|
||||
@property
|
||||
def armor(self):
|
||||
slots = self.slots
|
||||
return sum(
|
||||
(
|
||||
# armor is listed using its defense, so we remove 10 from it
|
||||
# (11 is base no-armor value in Knave)
|
||||
getattr(slots[WieldLocation.BODY], "armor", 1),
|
||||
# shields and helmets are listed by their bonus to armor
|
||||
getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
|
||||
getattr(slots[WieldLocation.HEAD], "armor", 0),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def weapon(self):
|
||||
# first checks two-handed wield, then one-handed; the two
|
||||
# should never appear simultaneously anyhow (checked in `move` method).
|
||||
slots = self.slots
|
||||
weapon = slots[WieldLocation.TWO_HANDS]
|
||||
if not weapon:
|
||||
weapon = slots[WieldLocation.WEAPON_HAND]
|
||||
if not weapon:
|
||||
weapon = WeaponEmptyHand()
|
||||
return weapon
|
||||
|
||||
```
|
||||
|
||||
In the `.armor()` method we get the item (if any) out of each relevant wield-slot (body, shield, head),
|
||||
and grab their `armor` Attribute. We then `sum()` them all up.
|
||||
|
||||
In `.weapon()`, we simply check which of the possible weapon slots (weapon-hand or two-hands) have
|
||||
something in them. If not we fall back to the 'fake' weapon `WeaponEmptyHand` which is just a 'dummy'
|
||||
object that represents your bare hands with damage and all.
|
||||
(created in [The Object tutorial](./Beginner-Tutorial-Objects.md#your-bare-hands) earlier).
|
||||
|
||||
|
||||
## Extra credits
|
||||
|
||||
This covers the basic functionality of the equipment handler. There are other useful methods that
|
||||
can be added:
|
||||
|
||||
- Given an item, figure out which equipment slot it is currently in
|
||||
- Make a string representing the current loadout
|
||||
- Get everything in the backpack (only)
|
||||
- Get all wieldable items (weapons, shields) from backpack
|
||||
- Get all usable items (items with a use-location of `BACKPACK`) from the backpack
|
||||
|
||||
Experiment with adding those. A full example is found in
|
||||
[evennia/contrib/tutorials/evadventure/equipment.py](../../../api/evennia.contrib.tutorials.evadventure.equipment.md).
|
||||
|
||||
## Unit Testing
|
||||
|
||||
> Create a new module `mygame/evadventure/tests/test_equipment.py`.
|
||||
|
||||
```{sidebar}
|
||||
See [evennia/contrib/tutorials/evadventure/tests/test_equipment.py](../../../api/evennia.contrib.tutorials.evadventure.tests.test_equipment.md)
|
||||
for a finished testing example.
|
||||
```
|
||||
|
||||
To test the `EquipmentHandler`, easiest is create an `EvAdventureCharacter` (this should by now
|
||||
have `EquipmentHandler` available on itself as `.equipment`) and a few test objects; then test
|
||||
passing these into the handler's methods.
|
||||
|
||||
|
||||
```python
|
||||
# mygame/evadventure/tests/test_equipment.py
|
||||
|
||||
from evennia.utils import create
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
|
||||
from ..objects import EvAdventureRoom
|
||||
from ..enums import WieldLocation
|
||||
|
||||
class TestEquipment(BaseEvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
self.character = create.create_object(EvAdventureCharacter, key='testchar')
|
||||
self.helmet = create.create_object(EvAdventureHelmet, key="helmet")
|
||||
self.weapon = create.create_object(EvAdventureWeapon, key="weapon")
|
||||
|
||||
def test_add_remove):
|
||||
self.character.equipment.add(self.helmet)
|
||||
self.assertEqual(
|
||||
self.character.equipment.slots[WieldLocation.BACKPACK],
|
||||
[self.helmet]
|
||||
)
|
||||
self.character.equipment.remove(self.helmet)
|
||||
self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [])
|
||||
|
||||
# ...
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
_Handlers_ are useful for grouping functionality together. Now that we spent our time making the
|
||||
`EquipmentHandler`, we shouldn't need to worry about item-slots anymore - the handler 'handles' all
|
||||
the details for us. As long as we call its methods, the details can be forgotten about.
|
||||
|
||||
We also learned to use _hooks_ to tie _Knave_'s custom equipment handling into Evennia.
|
||||
|
||||
With `Characters`, `Objects` and now `Equipment` in place, we should be able to move on to character
|
||||
generation - where players get to make their own character!
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Non-Player-Characters (NPCs)
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
# In-game Objects and items
|
||||
|
||||
In the previous lesson we established what a 'Character' is in our game. Before we continue
|
||||
we also need to have a notion what an 'item' or 'object' is.
|
||||
|
||||
Looking at _Knave_'s item lists, we can get some ideas of what we need to track:
|
||||
|
||||
- `size` - this is how many 'slots' the item uses in the character's inventory.
|
||||
- `value` - a base value if we want to sell or buy the item.
|
||||
- `inventory_use_slot` - some items can be worn or wielded. For example, a helmet needs to be
|
||||
worn on the head and a shield in the shield hand. Some items can't be used this way at all, but
|
||||
only belong in the backpack.
|
||||
- `obj_type` - Which 'type' of item this is.
|
||||
|
||||
|
||||
## New Enums
|
||||
|
||||
We added a few enumberations for Abilities back in the [Utilities tutorial](./Beginner-Tutorial-Utilities.md).
|
||||
Before we continue, let's expand with enums for use-slots and object types.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/enums.py
|
||||
|
||||
# ...
|
||||
|
||||
class WieldLocation(Enum):
|
||||
|
||||
BACKPACK = "backpack"
|
||||
WEAPON_HAND = "weapon_hand"
|
||||
SHIELD_HAND = "shield_hand"
|
||||
TWO_HANDS = "two_handed_weapons"
|
||||
BODY = "body" # armor
|
||||
HEAD = "head" # helmets
|
||||
|
||||
class ObjType(Enum):
|
||||
|
||||
WEAPON = "weapon"
|
||||
ARMOR = "armor"
|
||||
SHIELD = "shield"
|
||||
HELMET = "helmet"
|
||||
CONSUMABLE = "consumable"
|
||||
GEAR = "gear"
|
||||
MAGIC = "magic"
|
||||
QUEST = "quest"
|
||||
TREASURE = "treasure"
|
||||
```
|
||||
|
||||
Once we have these enums, we will use them for referencing things.
|
||||
|
||||
## The base object
|
||||
|
||||
> Create a new module `mygame/evadventure/objects.py`
|
||||
|
||||
```{sidebar}
|
||||
[evennia/contrib/tutorials/evadventure/objects.py](../../../api/evennia.contrib.tutorials.evadventure.objects.md) has
|
||||
a full set of objects implemented.
|
||||
```
|
||||
<div style="clear: right;"></div>
|
||||
|
||||
We will make a base `EvAdventureObject` class off Evennia's standard `DefaultObject`. We will then add
|
||||
child classes to represent the relevant types:
|
||||
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
from evennia import AttributeProperty, DefaultObject
|
||||
from evennia.utils.utils import make_iter
|
||||
from .utils import get_obj_stats
|
||||
from .enums import WieldLocation, ObjType
|
||||
|
||||
|
||||
class EvAdventureObject(DefaultObject):
|
||||
"""
|
||||
Base for all evadventure objects.
|
||||
|
||||
"""
|
||||
inventory_use_slot = WieldLocation.BACKPACK
|
||||
size = AttributeProperty(1, autocreate=False)
|
||||
value = AttributeProperty(0, autocreate=False)
|
||||
|
||||
# this can be either a single type or a list of types (for objects able to be
|
||||
# act as multiple). This is used to tag this object during creation.
|
||||
obj_type = ObjType.GEAR
|
||||
|
||||
def at_object_creation(self):
|
||||
"""Called when this object is first created. We convert the .obj_type
|
||||
property to a database tag."""
|
||||
|
||||
for obj_type in make_iter(self.obj_type):
|
||||
self.tags.add(self.obj_type.value, category="obj_type")
|
||||
|
||||
def get_help(self):
|
||||
"""Get any help text for this item"""
|
||||
return "No help for this item"
|
||||
```
|
||||
|
||||
### Using Attributes or not
|
||||
|
||||
In theory, `size` and `value` does not change and _could_ also be just set as a regular Python
|
||||
property on the class:
|
||||
|
||||
```python
|
||||
class EvAdventureObject(DefaultObject):
|
||||
inventory_use_slot = WieldLocation.BACKPACK
|
||||
size = 1
|
||||
value = 0
|
||||
```
|
||||
|
||||
The problem with this is that if we want to make a new object of `size 3` and `value 20`, we have to
|
||||
make a new class for it. We can't change it on the fly because the change would only be in memory and
|
||||
be lost on next server reload.
|
||||
|
||||
Because we use `AttributeProperties`, we can set `size` and `value` to whatever we like when we
|
||||
create the object (or later), and the Attributes will remember our changes to that object indefinitely.
|
||||
|
||||
To make this a little more efficient, we use `autocreate=False`. Normally when you create a
|
||||
new object with defined `AttributeProperties`, a matching `Attribute` is immediately created at
|
||||
the same time. So normally, the object would be created along with two Attributes `size` and `value`.
|
||||
With `autocreate=False`, no Attribute will be created _unless the default is changed_. That is, as
|
||||
long as your object has `size=1` no database `Attribute` will be created at all. This saves time and
|
||||
resources when creating large number of objects.
|
||||
|
||||
The drawback is that since no Attribute is created you can't refer to it
|
||||
with `obj.db.size` or `obj.attributes.get("size")` _unless you change its default_. You also can't query
|
||||
the database for all objects with `size=1`, since most objects would not yet have an in-database
|
||||
`size` Attribute to search for.
|
||||
|
||||
In our case, we'll only refer to these properties as `obj.size` etc, and have no need to find
|
||||
all objects of a particular size. So we should be safe.
|
||||
|
||||
### Creating tags in `at_object_creation`
|
||||
|
||||
The `at_object_creation` is a method Evennia calls on every child of `DefaultObject` whenever it is
|
||||
first created.
|
||||
|
||||
We do a tricky thing here, converting our `.obj_type` to one or more [Tags](../../../Components/Tags.md). Tagging the
|
||||
object like this means you can later efficiently find all objects of a given type (or combination of
|
||||
types) with Evennia's search functions:
|
||||
|
||||
```python
|
||||
from .enums import ObjType
|
||||
from evennia.utils import search
|
||||
|
||||
# get all shields in the game
|
||||
all_shields = search.search_object_by_tag(ObjType.SHIELD.value, category="obj_type")
|
||||
```
|
||||
|
||||
We allow `.obj_type` to be given as a single value or a list of values. We use `make_iter` from the
|
||||
evennia utility library to make sure we don't balk at either. This means you could have a Shield that
|
||||
is also Magical, for example.
|
||||
|
||||
## Other object types
|
||||
|
||||
Some of the other object types are very simple so far.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
from evennia import AttributeProperty, DefaultObject
|
||||
from .enums import ObjType
|
||||
|
||||
class EvAdventureObject(DefaultObject):
|
||||
# ...
|
||||
|
||||
|
||||
class EvAdventureQuestObject(EvAdventureObject):
|
||||
"""Quest objects should usually not be possible to sell or trade."""
|
||||
obj_type = ObjType.QUEST
|
||||
|
||||
class EvAdventureTreasure(EvAdventureObject):
|
||||
"""Treasure is usually just for selling for coin"""
|
||||
obj_type = ObjType.TREASURE
|
||||
value = AttributeProperty(100, autocreate=False)
|
||||
|
||||
```
|
||||
|
||||
## Consumables
|
||||
|
||||
A 'consumable' is an item that has a certain number of 'uses'. Once fully consumed, it can't be used
|
||||
anymore. An example would be a health potion.
|
||||
|
||||
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureConsumable(EvAdventureObject):
|
||||
"""An item that can be used up"""
|
||||
|
||||
obj_type = ObjType.CONSUMABLE
|
||||
value = AttributeProperty(0.25, autocreate=False)
|
||||
uses = AttributeProperty(1, autocreate=False)
|
||||
|
||||
def at_pre_use(self, user, *args, **kwargs):
|
||||
"""Called before using. If returning False, abort use."""
|
||||
return uses > 0
|
||||
|
||||
def at_use(self, user, *args, **kwargs):
|
||||
"""Called when using the item"""
|
||||
pass
|
||||
|
||||
def at_post_use(self. user, *args, **kwargs):
|
||||
"""Called after using the item"""
|
||||
# detract a usage, deleting the item if used up.
|
||||
self.uses -= 1
|
||||
if self.uses <= 0:
|
||||
user.msg(f"{self.key} was used up.")
|
||||
self.delete()
|
||||
```
|
||||
|
||||
What exactly each consumable does will vary - we will need to implement children of this class
|
||||
later, overriding `at_use` with different effects.
|
||||
|
||||
## Weapons
|
||||
|
||||
All weapons need properties that describe how efficient they are in battle.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
from .enums import WieldLocation, ObjType, Ability
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureWeapon(EvAdventureObject):
|
||||
"""Base class for all weapons"""
|
||||
|
||||
obj_type = ObjType.WEAPON
|
||||
inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND, autocreate=False)
|
||||
quality = AttributeProperty(3, autocreate=False)
|
||||
|
||||
attack_type = AttibuteProperty(Ability.STR, autocreate=False)
|
||||
defend_type = AttibuteProperty(Ability.ARMOR, autocreate=False)
|
||||
|
||||
damage_roll = AttibuteProperty("1d6", autocreate=False)
|
||||
```
|
||||
|
||||
The `quality` is something we need to track in _Knave_. When getting critical failures on attacks,
|
||||
a weapon's quality will go down. When it reaches 0, it will break.
|
||||
|
||||
The attack/defend type tracks how we resolve attacks with the weapon, like `roll + STR vs ARMOR + 10`.
|
||||
|
||||
## Magic
|
||||
|
||||
In _Knave_, anyone can use magic if they are wielding a rune stone (our name for spell books) in both
|
||||
hands. You can only use a rune stone once per rest. So a rune stone is an example of a 'magical weapon'
|
||||
that is also a 'consumable' of sorts.
|
||||
|
||||
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
# ...
|
||||
class EvAdventureConsumable(EvAdventureObject):
|
||||
# ...
|
||||
|
||||
class EvAdventureWeapon(EvAdventureObject):
|
||||
# ...
|
||||
|
||||
class EvAdventureRuneStone(EvAdventureWeapon, EvAdventureConsumable):
|
||||
"""Base for all magical rune stones"""
|
||||
|
||||
obj_type = (ObjType.WEAPON, ObjType.MAGIC)
|
||||
inventory_use_slot = WieldLocation.TWO_HANDS # always two hands for magic
|
||||
quality = AttributeProperty(3, autocreate=False)
|
||||
|
||||
attack_type = AttibuteProperty(Ability.INT, autocreate=False)
|
||||
defend_type = AttibuteProperty(Ability.DEX, autocreate=False)
|
||||
|
||||
damage_roll = AttibuteProperty("1d8", autocreate=False)
|
||||
|
||||
def at_post_use(self, user, *args, **kwargs):
|
||||
"""Called after usage/spell was cast"""
|
||||
self.uses -= 1
|
||||
# we don't delete the rune stone here, but
|
||||
# it must be reset on next rest.
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the rune stone (normally after rest)"""
|
||||
self.uses = 1
|
||||
```
|
||||
|
||||
We make the rune stone a mix of weapon and consumable. Note that we don't have to add `.uses`
|
||||
again, it's inherited from `EvAdventureConsumable` parent. The `at_pre_use` and `at_use` methods
|
||||
are also inherited; we only override `at_post_use` since we don't want the runestone to be deleted
|
||||
when it runs out of uses.
|
||||
|
||||
We add a little convenience method `refresh` - we should call this when the character rests, to
|
||||
make the runestone active again.
|
||||
|
||||
Exactly what rune stones _do_ will be implemented in the `at_use` methods of subclasses to this
|
||||
base class. Since magic in _Knave_ tends to be pretty custom, it makes sense that it will lead to a lot
|
||||
of custom code.
|
||||
|
||||
|
||||
## Armor
|
||||
|
||||
Armor, shields and helmets increase the `ARMOR` stat of the character. In _Knave_, what is stored is the
|
||||
defense value of the armor (values 11-20). We will instead store the 'armor bonus' (1-10). As we know,
|
||||
defending is always `bonus + 10`, so the result will be the same - this means
|
||||
we can use `Ability.ARMOR` as any other defensive ability without worrying about a special case.
|
||||
|
||||
``
|
||||
```python
|
||||
# mygame/evadventure/objects.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureAmor(EvAdventureObject):
|
||||
obj_type = ObjType.ARMOR
|
||||
inventory_use_slot = WieldLocation.BODY
|
||||
|
||||
armor = AttributeProperty(1, autocreate=False)
|
||||
quality = AttributeProperty(3, autocreate=False)
|
||||
|
||||
|
||||
class EvAdventureShield(EvAdventureArmor):
|
||||
obj_type = ObjType.SHIELD
|
||||
inventory_use_slot = WieldLocation.SHIELD_HAND
|
||||
|
||||
|
||||
class EvAdventureHelmet(EvAdventureArmor):
|
||||
obj_type = ObjType.HELMET
|
||||
inventory_use_slot = WieldLocation.HEAD
|
||||
```
|
||||
|
||||
## Your Bare hands
|
||||
|
||||
This is a 'dummy' object that is not stored in the database. We will use this in the upcoming
|
||||
[Equipment tutorial lesson](./Beginner-Tutorial-Equipment.md) to represent when you have 'nothing'
|
||||
in your hands. This way we don't need to add any special case for this.
|
||||
|
||||
```python
|
||||
class WeaponEmptyHand:
|
||||
obj_type = ObjType.WEAPON
|
||||
key = "Empty Fists"
|
||||
inventory_use_slot = WieldLocation.WEAPON_HAND
|
||||
attack_type = Ability.STR
|
||||
defense_type = Ability.ARMOR
|
||||
damage_roll = "1d4"
|
||||
quality = 100000 # let's assume fists are always available ...
|
||||
|
||||
def __repr__(self):
|
||||
return "<WeaponEmptyHand>"
|
||||
```
|
||||
|
||||
## Testing and Extra credits
|
||||
|
||||
Remember the `get_obj_stats` function from the [Utility Tutorial](./Beginner-Tutorial-Utilities.md) earlier?
|
||||
We had to use dummy-values since we didn't yet know how we would store properties on Objects in the game.
|
||||
|
||||
Well, we just figured out all we need! You can go back and update `get_obj_stats` to properly read the data
|
||||
from the object it receives.
|
||||
|
||||
When you change this function you must also update the related unit test - so your existing test becomes a
|
||||
nice way to test your new Objects as well! Add more tests showing the output of feeding different object-types
|
||||
to `get_obj_stats`.
|
||||
|
||||
Try it out yourself. If you need help, a finished utility example is found in [evennia/contrib/tutorials/evadventure/utils.py](get_obj_stats).
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
# Part 3: How we get there
|
||||
|
||||
```{warning}
|
||||
The tutorial game is under development and is not yet complete, nor tested. Use the existing
|
||||
lessons as inspiration and to help get you going, but don't expect out-of-the-box perfection
|
||||
from it at this time.
|
||||
```
|
||||
|
||||
```{sidebar} Beginner Tutorial Parts
|
||||
- [Introduction](../Beginner-Tutorial-Intro.md)
|
||||
<br>Getting set up.
|
||||
- Part 1: [What we have](../Part1/Beginner-Tutorial-Part1-Intro.md)
|
||||
<br>A tour of Evennia and how to use the tools, including an introduction to Python.
|
||||
- Part 2: [What we want](../Part2/Beginner-Tutorial-Part2-Intro.md)
|
||||
<br>Planning our tutorial game and what to think about when planning your own in the future.
|
||||
- **Part 3: [How we get there](./Beginner-Tutorial-Part3-Intro.md)**
|
||||
<br>Getting down to the meat of extending Evennia to make our game
|
||||
- Part 4: [Using what we created](../Part4/Beginner-Tutorial-Part4-Intro.md)
|
||||
<br>Building a tech-demo and world content to go with our code
|
||||
- Part 5: [Showing the world](../Part5/Beginner-Tutorial-Part5-Intro.md)
|
||||
<br>Taking our new game online and let players try it out
|
||||
```
|
||||
|
||||
In part three of the Evennia Beginner tutorial we will go through the actual creation of
|
||||
our tutorial game _EvAdventure_, based on the [Knave](https://www.drivethrurpg.com/product/250888/Knave)
|
||||
RPG ruleset.
|
||||
|
||||
This is a big part. You'll be seeing a lot of code and there are plenty of lessons to go through.
|
||||
Take your time!
|
||||
|
||||
If you followed the previous parts of this tutorial you will have some notions about Python and where to
|
||||
find and make use of things in Evennia. We also have a good idea of the type of game we will
|
||||
create.
|
||||
|
||||
Even if this is not the game-style you are interested in, following along will give you a lot
|
||||
of experience using Evennia and be really helpful for doing your own thing later!
|
||||
|
||||
Fully coded examples of all code we make in this part can be found in the
|
||||
[evennia/contrib/tutorials/evadventure](../../../api/evennia.contrib.tutorials.evadventure.md) package.
|
||||
|
||||
## Lessons
|
||||
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 1
|
||||
|
||||
Beginner-Tutorial-Utilities
|
||||
Beginner-Tutorial-Rules
|
||||
Beginner-Tutorial-Characters
|
||||
Beginner-Tutorial-Objects
|
||||
Beginner-Tutorial-Equipment
|
||||
Beginner-Tutorial-Chargen
|
||||
Beginner-Tutorial-Rooms
|
||||
Beginner-Tutorial-NPCs
|
||||
Beginner-Tutorial-Turnbased-Combat
|
||||
Beginner-Tutorial-Quests
|
||||
Beginner-Tutorial-Shops
|
||||
Beginner-Tutorial-Dungeon
|
||||
Beginner-Tutorial-Commands
|
||||
```
|
||||
|
||||
## Table of Contents
|
||||
|
||||
```{toctree}
|
||||
|
||||
Beginner-Tutorial-Utilities
|
||||
Beginner-Tutorial-Rules
|
||||
Beginner-Tutorial-Characters
|
||||
Beginner-Tutorial-Objects
|
||||
Beginner-Tutorial-Equipment
|
||||
Beginner-Tutorial-Chargen
|
||||
Beginner-Tutorial-Rooms
|
||||
Beginner-Tutorial-NPCs
|
||||
Beginner-Tutorial-Turnbased-Combat
|
||||
Beginner-Tutorial-Quests
|
||||
Beginner-Tutorial-Shops
|
||||
Beginner-Tutorial-Dungeon
|
||||
Beginner-Tutorial-Commands
|
||||
```
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Game Quests
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# In-game Rooms
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,633 +0,0 @@
|
|||
# Rules and dice rolling
|
||||
|
||||
In _EvAdventure_ we have decided to use the [Knave](https://www.drivethrurpg.com/product/250888/Knave)
|
||||
RPG ruleset. This is commercial, but released under Creative Commons 4.0, meaning it's okay to share and
|
||||
adapt _Knave_ for any purpose, even commercially. If you don't want to buy it but still follow
|
||||
along, you can find a [free fan-version here](http://abominablefancy.blogspot.com/2018/10/knaves-fancypants.html).
|
||||
|
||||
## Summary of _Knave_ rules
|
||||
|
||||
Knave, being inspired by early Dungeons & Dragons, is very simple.
|
||||
|
||||
- It uses six Ability bonuses
|
||||
_Strength_ (STR), _Dexterity_ (DEX), _Constitution_ (CON), _Intelligence_ (INT), _Wisdom_ (WIS)
|
||||
and _Charisma_ (CHA). These are rated from `+1` to `+10`.
|
||||
- Rolls are made with a twenty-sided die (`1d20`), usually adding a suitable Ability bonus to the roll.
|
||||
- If you roll _with advantage_, you roll `2d20` and pick the
|
||||
_highest_ value, If you roll _with disadvantage_, you roll `2d20` and pick the _lowest_.
|
||||
- Rolling a natural `1` is a _critical failure_. A natural `20` is a _critical success_. Rolling such
|
||||
in combat means your weapon or armor loses quality, which will eventually destroy it.
|
||||
- A _saving throw_ (trying to succeed against the environment) means making a roll to beat `15` (always).
|
||||
So if you are lifting a heavy stone and have `STR +2`, you'd roll `1d20 + 2` and hope the result
|
||||
is higher than `15`.
|
||||
- An _opposed saving throw_ means beating the enemy's suitable Ability 'defense', which is always their
|
||||
`Ability bonus + 10`. So if you have `STR +1` and are arm wrestling someone with `STR +2`, you roll
|
||||
`1d20 + 1` and hope to roll higher than `2 + 10 = 12`.
|
||||
- A special bonus is `Armor`, `+1` is unarmored, additional armor is given by equipment. Melee attacks
|
||||
test `STR` versus the `Armor` defense value while ranged attacks uses `WIS` vs `Armor`.
|
||||
- _Knave_ has no skills or classes. Everyone can use all items and using magic means having a special
|
||||
'rune stone' in your hands; one spell per stone and day.
|
||||
- A character has `CON + 10` carry 'slots'. Most normal items uses one slot, armor and large weapons uses
|
||||
two or three.
|
||||
- Healing is random, `1d8 + CON` health healed after food and sleep.
|
||||
- Monster difficulty is listed by hy many 1d8 HP they have; this is called their "hit die" or HD. If
|
||||
needing to test Abilities, monsters have HD bonus in every Ability.
|
||||
- Monsters have a _morale rating_. When things go bad, they have a chance to panic and flee if
|
||||
rolling `2d6` over their morale rating.
|
||||
- All Characters in _Knave_ are mostly randomly generated. HP is `<level>d8` but we give every
|
||||
new character max HP to start.
|
||||
- _Knave_ also have random tables, such as for starting equipment and to see if dying when
|
||||
hitting 0. Death, if it happens, is permanent.
|
||||
|
||||
|
||||
## Making a rule module
|
||||
|
||||
> Create a new module mygame/evadventure/rules.py
|
||||
|
||||
```{sidebar}
|
||||
A complete version of the rule module is found in
|
||||
[evennia/contrib/tutorials/evadventure/rules.py](../../../api/evennia.contrib.tutorials.evadventure.rules.md).
|
||||
```
|
||||
There are three broad sets of rules for most RPGS:
|
||||
|
||||
- Character generation rules, often only used during character creation
|
||||
- Regular gameplay rules - rolling dice and resolving game situations
|
||||
- Character improvement - getting and spending experience to improve the character
|
||||
|
||||
We want our `rules` module to cover as many aspeects of what we'd otherwise would have to look up
|
||||
in a rulebook.
|
||||
|
||||
|
||||
## Rolling dice
|
||||
|
||||
We will start by making a dice roller. Let's group all of our dice rolling into a structure like this
|
||||
(not functional code yet):
|
||||
|
||||
```python
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
def roll(...):
|
||||
# get result of one generic roll, for any type and number of dice
|
||||
|
||||
def roll_with_advantage_or_disadvantage(...)
|
||||
# get result of normal d20 roll, with advantage/disadvantage (or not)
|
||||
|
||||
def saving_throw(...):
|
||||
# do a saving throw against a specific target number
|
||||
|
||||
def opposed_saving_throw(...):
|
||||
# do an opposed saving throw against a target's defense
|
||||
|
||||
def roll_random_table(...):
|
||||
# make a roll against a random table (loaded elsewere)
|
||||
|
||||
def morale_check(...):
|
||||
# roll a 2d6 morale check for a target
|
||||
|
||||
def heal_from_rest(...):
|
||||
# heal 1d8 when resting+eating, but not more than max value.
|
||||
|
||||
def roll_death(...):
|
||||
# roll to determine penalty when hitting 0 HP.
|
||||
|
||||
|
||||
dice = EvAdventureRollEngine()
|
||||
|
||||
```
|
||||
```{sidebar}
|
||||
This groups all dice-related code into one 'container' that is easy to import. But it's mostly a matter
|
||||
of taste. You _could_ also break up the class' methods into normal functions at the top-level of the
|
||||
module if you wanted.
|
||||
```
|
||||
|
||||
This structure (called a _singleton_) means we group all dice rolls into one class that we then initiate
|
||||
into a variable `dice` at the end of the module. This means that we can do the following from other
|
||||
modules:
|
||||
|
||||
```python
|
||||
from .rules import dice
|
||||
|
||||
dice.roll("1d8")
|
||||
```
|
||||
|
||||
### Generic dice roller
|
||||
|
||||
We want to be able to do `roll("1d20")` and get a random result back from the roll.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
from random import randint
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
def roll(self, roll_string):
|
||||
"""
|
||||
Roll XdY dice, where X is the number of dice
|
||||
and Y the number of sides per die.
|
||||
|
||||
Args:
|
||||
roll_string (str): A dice string on the form XdY.
|
||||
Returns:
|
||||
int: The result of the roll.
|
||||
|
||||
"""
|
||||
|
||||
# split the XdY input on the 'd' one time
|
||||
number, diesize = roll_string.split("d", 1)
|
||||
|
||||
# convert from string to integers
|
||||
number = int(number)
|
||||
diesize = int(diesize)
|
||||
|
||||
# make the roll
|
||||
return sum(randint(1, diesize) for _ in range(number))
|
||||
```
|
||||
|
||||
```{sidebar}
|
||||
For this tutorial we have opted to not use any contribs, so we create
|
||||
our own dice roller. But normally you could instead use the [dice](../../../Contribs/Contrib-Dice.md) contrib for this.
|
||||
We'll point out possible helpful contribs in sidebars as we proceed.
|
||||
```
|
||||
|
||||
The `randint` standard Python library module produces a random integer
|
||||
in a specific range. The line
|
||||
|
||||
```python
|
||||
sum(randint(1, diesize) for _ in range(number))
|
||||
```
|
||||
works like this:
|
||||
|
||||
- For a certain `number` of times ...
|
||||
- ... create a random integer between `1` and `diesize` ...
|
||||
- ... and `sum` all those integers together.
|
||||
|
||||
You could write the same thing less compactly like this:
|
||||
|
||||
```python
|
||||
rolls = []
|
||||
for _ in range(number):
|
||||
random_result = randint(1, diesize)
|
||||
rolls.append(random_result)
|
||||
return sum(rolls)
|
||||
```
|
||||
|
||||
```{sidebar}
|
||||
Note that `range` generates a value `0...number-1`. We use `_` in the `for` loop to
|
||||
indicate we don't really care what this value is - we just want to repeat the loop
|
||||
a certain amount of times.
|
||||
```
|
||||
|
||||
We don't ever expect end users to call this method; if we did, we would have to validate the inputs
|
||||
much more - We would have to make sure that `number` or `diesize` are valid inputs and not
|
||||
crazy big so the loop takes forever!
|
||||
|
||||
### Rolling with advantage
|
||||
|
||||
Now that we have the generic roller, we can start using it to do a more complex roll.
|
||||
|
||||
```
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
def roll(roll_string):
|
||||
# ...
|
||||
|
||||
def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False):
|
||||
|
||||
if not (advantage or disadvantage) or (advantage and disadvantage):
|
||||
# normal roll - advantage/disadvantage not set or they cancel
|
||||
# each other out
|
||||
return self.roll("1d20")
|
||||
elif advantage:
|
||||
# highest of two d20 rolls
|
||||
return max(self.roll("1d20"), self.roll("1d20"))
|
||||
else:
|
||||
# disadvantage - lowest of two d20 rolls
|
||||
return min(self.roll("1d20"), self.roll("1d20"))
|
||||
```
|
||||
|
||||
The `min()` and `max()` functions are standard Python fare for getting the biggest/smallest
|
||||
of two arguments.
|
||||
|
||||
### Saving throws
|
||||
|
||||
We want the saving throw to itself figure out if it succeeded or not. This means it needs to know
|
||||
the Ability bonus (like STR `+1`). It would be convenient if we could just pass the entity
|
||||
doing the saving throw to this method, tell it what type of save was needed, and then
|
||||
have it figure things out:
|
||||
|
||||
```python
|
||||
result, quality = dice.saving_throw(character, Ability.STR)
|
||||
```
|
||||
The return will be a boolean `True/False` if they pass, as well as a `quality` that tells us if
|
||||
a perfect fail/success was rolled or not.
|
||||
|
||||
To make the saving throw method this clever, we need to think some more about how we want to store our
|
||||
data on the character.
|
||||
|
||||
For our purposes it sounds reasonable that we will be using [Attributes](../../../Components/Attributes.md) for storing
|
||||
the Ability scores. To make it easy, we will name them the same as the
|
||||
[Enum values](./Beginner-Tutorial-Utilities.md#enums) we set up in the previous lesson. So if we have
|
||||
an enum `STR = "strength"`, we want to store the Ability on the character as an Attribute `strength`.
|
||||
|
||||
From the Attribute documentation, we can see that we can use `AttributeProperty` to make it so the
|
||||
Attribute is available as `character.strength`, and this is what we will do.
|
||||
|
||||
So, in short, we'll create the saving throws method with the assumption that we will be able to do
|
||||
`character.strength`, `character.constitution`, `character.charisma` etc to get the relevant Abilities.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
# ...
|
||||
from .enums import Ability
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
def roll(...)
|
||||
# ...
|
||||
|
||||
def roll_with_advantage_or_disadvantage(...)
|
||||
# ...
|
||||
|
||||
def saving_throw(self, character, bonus_type=Ability.STR, target=15,
|
||||
advantage=False, disadvantage=False):
|
||||
"""
|
||||
Do a saving throw, trying to beat a target.
|
||||
|
||||
Args:
|
||||
character (Character): A character (assumed to have Ability bonuses
|
||||
stored on itself as Attributes).
|
||||
bonus_type (Ability): A valid Ability bonus enum.
|
||||
target (int): The target number to beat. Always 15 in Knave.
|
||||
advantage (bool): If character has advantage on this roll.
|
||||
disadvantage (bool): If character has disadvantage on this roll.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple (bool, Ability), showing if the throw succeeded and
|
||||
the quality is one of None or Ability.CRITICAL_FAILURE/SUCCESS
|
||||
|
||||
"""
|
||||
|
||||
# make a roll
|
||||
dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage)
|
||||
|
||||
# figure out if we had critical failure/success
|
||||
quality = None
|
||||
if dice_roll == 1:
|
||||
quality = Ability.CRITICAL_FAILURE
|
||||
elif dice_roll == 20:
|
||||
quality = Ability.CRITICAL_SUCCESS
|
||||
|
||||
# figure out bonus
|
||||
bonus = getattr(character, bonus_type.value, 1)
|
||||
|
||||
# return a tuple (bool, quality)
|
||||
return (dice_roll + bonus) > target, quality
|
||||
```
|
||||
|
||||
The `getattr(obj, attrname, default)` function is a very useful Python tool for getting an attribute
|
||||
off an object and getting a default value if the attribute is not defined.
|
||||
|
||||
### Opposed saving throw
|
||||
|
||||
With the building pieces we already created, this method is simple. Remember that the defense you have
|
||||
to beat is always the relevant bonus + 10 in _Knave_. So if the enemy defends with `STR +3`, you must
|
||||
roll higher than `13`.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
from .enums import Ability
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
def roll(...):
|
||||
# ...
|
||||
|
||||
def roll_with_advantage_or_disadvantage(...):
|
||||
# ...
|
||||
|
||||
def saving_throw(...):
|
||||
# ...
|
||||
|
||||
def opposed_saving_throw(self, attacker, defender,
|
||||
attack_type=Ability.STR, defense_type=Ability.ARMOR,
|
||||
advantage=False, disadvantage=False):
|
||||
defender_defense = getattr(defender, defense_type.value, 1) + 10
|
||||
result, quality = self.saving_throw(attacker, bonus_type=attack_type,
|
||||
target=defender_defense,
|
||||
advantage=advantave, disadvantage=disadvantage)
|
||||
|
||||
return result, quality
|
||||
```
|
||||
|
||||
### Morale check
|
||||
|
||||
We will make the assumption that the `morale` value is available from the creature simply as
|
||||
`monster.morale` - we need to remember to make this so later!
|
||||
|
||||
In _Knave_, a creature have roll with `2d6` equal or under its morale to not flee or surrender
|
||||
when things go south. The standard morale value is 9.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
# ...
|
||||
|
||||
def morale_check(self, defender):
|
||||
return self.roll("2d6") <= getattr(defender, "morale", 9)
|
||||
|
||||
```
|
||||
|
||||
### Roll for Healing
|
||||
|
||||
To be able to handle healing, we need to make some more assumptions about how we store
|
||||
health on game entities. We will need `hp_max` (the total amount of available HP) and `hp`
|
||||
(the current health value). We again assume these will be available as `obj.hp` and `obj.hp_max`.
|
||||
|
||||
According to the rules, after consuming a ration and having a full night's sleep, a character regains
|
||||
`1d8 + CON` HP.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
from .enums import Ability
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
# ...
|
||||
|
||||
def heal_from_rest(self, character):
|
||||
"""
|
||||
A night's rest retains 1d8 + CON HP
|
||||
|
||||
"""
|
||||
con_bonus = getattr(character, Ability.CON.value, 1)
|
||||
character.heal(self.roll("1d8") + con_bonus)
|
||||
```
|
||||
|
||||
We make another assumption here - that `character.heal()` is a thing. We tell this function how
|
||||
much the character should heal, and it will do so, making sure to not heal more than its max
|
||||
number of HPs
|
||||
|
||||
> Knowing what is available on the character and what rule rolls we need is a bit of a chicken-and-egg
|
||||
> problem. We will make sure to implement the matching _Character_ class next lesson.
|
||||
|
||||
|
||||
### Rolling on a table
|
||||
|
||||
We occasionally need to roll on a 'table' - a selection of choices. There are two main table-types
|
||||
we need to support:
|
||||
|
||||
Simply one element per row of the table (same odds to get each result).
|
||||
|
||||
| Result |
|
||||
|:------:|
|
||||
| item1 |
|
||||
| item2 |
|
||||
| item3 |
|
||||
| item4 |
|
||||
|
||||
This we will simply represent as a plain list
|
||||
|
||||
```python
|
||||
["item1", "item2", "item3", "item4"]
|
||||
```
|
||||
|
||||
Ranges per item (varying odds per result):
|
||||
|
||||
| Range | Result |
|
||||
|:-----:|:------:|
|
||||
| 1-5 | item1 |
|
||||
| 6-15 | item2 |
|
||||
| 16-19 | item3 |
|
||||
| 20 | item4 |
|
||||
|
||||
This we will represent as a list of tuples:
|
||||
|
||||
```python
|
||||
[("1-5", "item1"), ("6-15", "item2"), ("16-19", "item4"), ("20", "item5")]
|
||||
```
|
||||
|
||||
We also need to know what die to roll to get a result on the table (it may not always
|
||||
be obvious, and in some games you could be asked to roll a lower dice to only get
|
||||
early table results, for example).
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
from random import randint, choice
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
# ...
|
||||
|
||||
def roll_random_table(self, dieroll, table_choices):
|
||||
"""
|
||||
Args:
|
||||
dieroll (str): A die roll string, like "1d20".
|
||||
table_choices (iterable): A list of either single elements or
|
||||
of tuples.
|
||||
Returns:
|
||||
Any: A random result from the given list of choices.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If rolling dice giving results outside the table.
|
||||
|
||||
"""
|
||||
roll_result = self.roll(dieroll)
|
||||
|
||||
if isinstance(table_choices[0], (tuple, list)):
|
||||
# the first element is a tuple/list; treat as on the form [("1-5", "item"),...]
|
||||
for (valrange, choice) in table_choices:
|
||||
minval, *maxval = valrange.split("-", 1)
|
||||
minval = abs(int(minval))
|
||||
maxval = abs(int(maxval[0]) if maxval else minval)
|
||||
|
||||
if minval <= roll_result <= maxval:
|
||||
return choice
|
||||
|
||||
# if we get here we must have set a dieroll producing a value
|
||||
# outside of the table boundaries - raise error
|
||||
raise RuntimeError("roll_random_table: Invalid die roll")
|
||||
else:
|
||||
# a simple regular list
|
||||
roll_result = max(1, min(len(table_choices), roll_result))
|
||||
return table_choices[roll_result - 1]
|
||||
```
|
||||
Check that you understand what this does.
|
||||
|
||||
This may be confusing:
|
||||
```python
|
||||
minval, *maxval = valrange.split("-", 1)
|
||||
minval = abs(int(minval))
|
||||
maxval = abs(int(maxval[0]) if maxval else minval)
|
||||
```
|
||||
|
||||
If `valrange` is the string `1-5`, then `valrange.split("-", 1)` would result in a tuple `("1", "5")`.
|
||||
But if the string was in fact just `"20"` (possible for a single entry in an RPG table), this would
|
||||
lead to an error since it would only split out a single element - and we expected two.
|
||||
|
||||
By using `*maxval` (with the `*`), `maxval` is told to expect _0 or more_ elements in a tuple.
|
||||
So the result for `1-5` will be `("1", ("5",))` and for `20` it will become `("20", ())`. In the line
|
||||
|
||||
```python
|
||||
maxval = abs(int(maxval[0]) if maxval else minval)
|
||||
```
|
||||
|
||||
we check if `maxval` actually has a value `("5",)` or if its empty `()`. The result is either
|
||||
`"5"` or the value of `minval`.
|
||||
|
||||
|
||||
### Roll for death
|
||||
|
||||
While original Knave suggests hitting 0 HP means insta-death, we will grab the optional "death table"
|
||||
from the "prettified" Knave's optional rules to make it a little less punishing. We also changed the
|
||||
result of `2` to 'dead' since we don't simulate 'dismemberment' in this tutorial:
|
||||
|
||||
| Roll | Result | -1d4 Loss of Ability |
|
||||
|:---: |:--------:|:--------------------:|
|
||||
| 1-2 | dead | -
|
||||
| 3 | weakened | STR |
|
||||
|4 | unsteady | DEX |
|
||||
| 5 | sickly | CON |
|
||||
| 6 | addled | INT |
|
||||
| 7 | rattled | WIS |
|
||||
| 8 | disfigured | CHA |
|
||||
|
||||
All the non-dead values map to a loss of 1d4 in one of the six Abilities (but you get HP back).
|
||||
We need to map back to this from the above table. One also cannot have less than -10 Ability bonus,
|
||||
if you do, you die too.
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/rules.py
|
||||
|
||||
death_table = (
|
||||
("1-2", "dead"),
|
||||
("3": "strength",
|
||||
("4": "dexterity"),
|
||||
("5": "constitution"),
|
||||
("6": "intelligence"),
|
||||
("7": "wisdom"),
|
||||
("8": "charisma"),
|
||||
)
|
||||
|
||||
|
||||
class EvAdventureRollEngine:
|
||||
|
||||
# ...
|
||||
|
||||
def roll_random_table(...)
|
||||
# ...
|
||||
|
||||
def roll_death(self, character):
|
||||
ability_name = self.roll_random_table("1d8", death_table)
|
||||
|
||||
if ability_name == "dead":
|
||||
# TODO - kill the character!
|
||||
pass
|
||||
else:
|
||||
loss = self.roll("1d4")
|
||||
|
||||
current_ability = getattr(character, ability_name)
|
||||
current_ability -= loss
|
||||
|
||||
if current_ability < -10:
|
||||
# TODO - kill the character!
|
||||
pass
|
||||
else:
|
||||
# refresh 1d4 health, but suffer 1d4 ability loss
|
||||
self.heal(character, self.roll("1d4")
|
||||
setattr(character, ability_name, current_ability)
|
||||
|
||||
character.msg(
|
||||
"You survive your brush with death, and while you recover "
|
||||
f"some health, you permanently lose {loss} {ability_name} instead."
|
||||
)
|
||||
|
||||
dice = EvAdventureRollEngine()
|
||||
```
|
||||
|
||||
Here we roll on the 'death table' from the rules to see what happens. We give the character
|
||||
a message if they survive, to let them know what happened.
|
||||
|
||||
We don't yet know what 'killing the character' technically means, so we mark this as `TODO` and
|
||||
return to it in a later lesson. We just know that we need to do _something_ here to kill off the
|
||||
character!
|
||||
|
||||
## Testing
|
||||
|
||||
> Make a new module `mygame/evadventure/tests/test_rules.py`
|
||||
|
||||
Testing the `rules` module will also showcase some very useful tools when testing.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/tests/test_rules.py
|
||||
|
||||
from unittest.mock import patch
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
from .. import rules
|
||||
|
||||
class TestEvAdventureRuleEngine(BaseEvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
"""Called before every test method"""
|
||||
super().setUp()
|
||||
self.roll_engine = rules.EvAdventureRollEngine()
|
||||
|
||||
@patch("evadventure.rules.randint")
|
||||
def test_roll(self, mock_randint):
|
||||
mock_randint.return_value = 4
|
||||
self.assertEqual(self.roll_engine.roll("1d6", 4)
|
||||
self.assertEqual(self.roll_engine.roll("2d6", 2 * 4)
|
||||
|
||||
# test of the other rule methods below ...
|
||||
```
|
||||
|
||||
As before, run the specific test with
|
||||
|
||||
evennia test --settings settings.py .evadventure.tests.test_rules
|
||||
|
||||
### Mocking and patching
|
||||
|
||||
```{sidebar}
|
||||
In [evennia/contrib/tutorials/evadventure/tests/test_rules.py](../../../api/evennia.contrib.tutorials.evadventure.tests.test_rules.md)
|
||||
has a complete example of rule testing.
|
||||
```
|
||||
The `setUp` method is a special method of the testing class. It will be run before every
|
||||
test method. We use `super().setUp()` to make sure the parent class' version of this method
|
||||
always fire. Then we create a fresh `EvAdventureRollEngine` we can test with.
|
||||
|
||||
In our test, we import `patch` from the `unittest.mock` library. This is a very useful tool for testing.
|
||||
Normally the `randint` function we imported in `rules` will return a random value. That's very hard to
|
||||
test for, since the value will be different every test.
|
||||
|
||||
With `@patch` (this is called a _decorator_), we temporarily replace `rules.randint` with a 'mock' - a
|
||||
dummy entity. This mock is passed into the testing method. We then take this `mock_randint` and set
|
||||
`.return_value = 4` on it.
|
||||
|
||||
Adding `return_value` to the mock means that every time this mock is called, it will return 4. For the
|
||||
duration of the test we can now check with `self.assertEqual` that our `roll` method always returns a
|
||||
result as-if the random result was 4.
|
||||
|
||||
There are [many resources for understanding mock](https://realpython.com/python-mock-library/), refer to
|
||||
them for further help.
|
||||
|
||||
> The `EvAdventureRollEngine` have many methods to test. We leave this as an extra exercise!
|
||||
|
||||
## Summary
|
||||
|
||||
This concludes all the core rule mechanics of _Knave_ - the rules used during play. We noticed here
|
||||
that we are going to soon need to establish how our _Character_ actually stores data. So we will
|
||||
address that next.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# In-game Shops
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Turn-based combat
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
# Code structure and Utilities
|
||||
|
||||
In this lesson we will set up the file structure for _EvAdventure_. We will make some
|
||||
utilities that will be useful later. We will also learn how to write _tests_.
|
||||
|
||||
## Folder structure
|
||||
|
||||
Create a new folder under your `mygame` folder, named `evadventure`. Inside it, create
|
||||
another folder `tests/` and make sure to put empty `__init__.py` files in both. This turns both
|
||||
folders into packages Python understands to import from.
|
||||
|
||||
```
|
||||
mygame/
|
||||
commands/
|
||||
evadventure/ <---
|
||||
__init__.py <---
|
||||
tests/ <---
|
||||
__init__.py <---
|
||||
__init__.py
|
||||
README.md
|
||||
server/
|
||||
typeclasses/
|
||||
web/
|
||||
world/
|
||||
|
||||
```
|
||||
|
||||
Importing anything from inside this folder from anywhere else under `mygame` will be done by
|
||||
|
||||
```python
|
||||
# from anywhere in mygame/
|
||||
from evadventure.yourmodulename import whatever
|
||||
```
|
||||
|
||||
This is the 'absolute path` type of import.
|
||||
|
||||
Between two modules both in `evadventure/`, you can use a 'relative' import with `.`:
|
||||
|
||||
```python
|
||||
# from a module inside mygame/evadventure
|
||||
from .yourmodulename import whatever
|
||||
```
|
||||
|
||||
From e.g. inside `mygame/evadventure/tests/` you can import from one level above using `..`:
|
||||
|
||||
```python
|
||||
# from mygame/evadventure/tests/
|
||||
from ..yourmodulename import whatever
|
||||
```
|
||||
|
||||
|
||||
## Enums
|
||||
|
||||
```{sidebar}
|
||||
A full example of the enum module is found in
|
||||
[evennia/contrib/tutorials/evadventure/enums.py](../../../api/evennia.contrib.tutorials.evadventure.enums.md).
|
||||
```
|
||||
Create a new file `mygame/evadventure/enums.py`.
|
||||
|
||||
An [enum](https://docs.python.org/3/library/enum.html) (enumeration) is a way to establish constants
|
||||
in Python. Best is to show an example:
|
||||
|
||||
```python
|
||||
# in a file mygame/evadventure/enums.py
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class Ability(Enum):
|
||||
|
||||
STR = "strength"
|
||||
|
||||
```
|
||||
|
||||
You access an enum like this:
|
||||
|
||||
```
|
||||
# from another module in mygame/evadventure
|
||||
|
||||
from .enums import Ability
|
||||
|
||||
Ability.STR # the enum itself
|
||||
Ability.STR.value # this is the string "strength"
|
||||
|
||||
```
|
||||
|
||||
Having enums is recommended practice. With them set up, it means we can make sure to refer to the
|
||||
same thing every time. Having all enums in one place also means you have a good overview of the
|
||||
constants you are dealing with.
|
||||
|
||||
The alternative would be to for example pass around a string `"constitution"`. If you mis-spell
|
||||
this (`"consitution"`), you would not necessarily know it right away - the error would happen later
|
||||
when the string is not recognized. If you make a typo getting `Ability.COM` instead of `Ability.CON`,
|
||||
Python will immediately raise an error since this enum is not recognized.
|
||||
|
||||
With enums you can also do nice direct comparisons like `if ability is Ability.WIS: <do stuff>`.
|
||||
|
||||
Note that the `Ability.STR` enum does not have the actual _value_ of e.g. your Strength.
|
||||
It's just a fixed label for the Strength ability.
|
||||
|
||||
Here is the `enum.py` module needed for _Knave_. It covers the basic aspects of
|
||||
rule systems we need to track (check out the _Knave_ rules. If you use another rule system you'll
|
||||
likely gradually expand on your enums as you figure out what you'll need).
|
||||
|
||||
```python
|
||||
# mygame/evadventure/enums.py
|
||||
|
||||
class Ability(Enum):
|
||||
"""
|
||||
The six base ability-bonuses and other
|
||||
abilities
|
||||
|
||||
"""
|
||||
|
||||
STR = "strength"
|
||||
DEX = "dexterity"
|
||||
CON = "constitution"
|
||||
INT = "intelligence"
|
||||
WIS = "wisdom"
|
||||
CHA = "charisma"
|
||||
|
||||
ARMOR = "armor"
|
||||
|
||||
CRITICAL_FAILURE = "critical_failure"
|
||||
CRITICAL_SUCCESS = "critical_success"
|
||||
|
||||
ALLEGIANCE_HOSTILE = "hostile"
|
||||
ALLEGIANCE_NEUTRAL = "neutral"
|
||||
ALLEGIANCE_FRIENDLY = "friendly"
|
||||
|
||||
|
||||
```
|
||||
|
||||
Here the `Ability` class holds basic properties of a character sheet.
|
||||
|
||||
|
||||
## Utility module
|
||||
|
||||
> Create a new module `mygame/evadventure/utils.py`
|
||||
|
||||
```{sidebar}
|
||||
An example of the utility module is found in
|
||||
[evennia/contrib/tutorials/evadventure/utils.py](../../../api/evennia.contrib.tutorials.evadventure.utils.md)
|
||||
```
|
||||
|
||||
This is for general functions we may need from all over. In this case we only picture one utility,
|
||||
a function that produces a pretty display of any object we pass to it.
|
||||
|
||||
This is an example of the string we want to see:
|
||||
|
||||
```
|
||||
Chipped Sword
|
||||
Value: ~10 coins [wielded in Weapon hand]
|
||||
|
||||
A simple sword used by mercenaries all over
|
||||
the world.
|
||||
|
||||
Slots: 1, Used from: weapon hand
|
||||
Quality: 3, Uses: None
|
||||
Attacks using strength against armor.
|
||||
Damage roll: 1d6
|
||||
```
|
||||
|
||||
Here's the start of how the function could look:
|
||||
|
||||
```python
|
||||
# in mygame/evadventure/utils.py
|
||||
|
||||
_OBJ_STATS = """
|
||||
|c{key}|n
|
||||
Value: ~|y{value}|n coins{carried}
|
||||
|
||||
{desc}
|
||||
|
||||
Slots: |w{size}|n, Used from: |w{use_slot_name}|n
|
||||
Quality: |w{quality}|n, Uses: |wuses|n
|
||||
Attacks using |w{attack_type_name}|n against |w{defense_type_name}|n
|
||||
Damage roll: |w{damage_roll}|n
|
||||
""".strip()
|
||||
|
||||
|
||||
def get_obj_stats(obj, owner=None):
|
||||
"""
|
||||
Get a string of stats about the object.
|
||||
|
||||
Args:
|
||||
obj (Object): The object to get stats for.
|
||||
owner (Object): The one currently owning/carrying `obj`, if any. Can be
|
||||
used to show e.g. where they are wielding it.
|
||||
Returns:
|
||||
str: A nice info string to display about the object.
|
||||
|
||||
"""
|
||||
return _OBJ_STATS.format(
|
||||
key=obj.key,
|
||||
value=10,
|
||||
carried="[Not carried]",
|
||||
desc=obj.db.desc,
|
||||
size=1,
|
||||
quality=3,
|
||||
uses="infinite"
|
||||
use_slot_name="backpack",
|
||||
attack_type_name="strength"
|
||||
defense_type_name="armor"
|
||||
damage_roll="1d6"
|
||||
)
|
||||
```
|
||||
Here we set up the string template with place holders for where every piece of info should go.
|
||||
Study this string so you understand what it does. The `|c`, `|y`, `|w` and `|n` markers are
|
||||
[Evennia color markup](../../../Concepts/Colors.md) for making the text cyan, yellow, white and neutral-color respectively.
|
||||
|
||||
We can guess some things, such that `obj.key` is the name of the object, and that `obj.db.desc` will
|
||||
hold its description (this is how it is in default Evennia).
|
||||
|
||||
But so far we have not established how to get any of the other properties like `size` or `attack_type`.
|
||||
So we just set them to dummy values. We'll need to get back to this when we have more code in place!
|
||||
|
||||
## Testing
|
||||
|
||||
```{important}
|
||||
It's useful for any game dev to know how to effectively test their code. So we'll try to include a
|
||||
*Testing* section at the end of each of the implementation lessons to follow. Writing tests for your code
|
||||
is optional but highly recommended; it can feel a little cumbersome at first, but you'll thank yourself later.
|
||||
```
|
||||
|
||||
> create a new module `mygame/evadventure/tests/test_utils.py`
|
||||
|
||||
How do you know if you made a typo in the code above? You could _manually_ test it by reloading your
|
||||
Evennia server and do the following from in-game:
|
||||
|
||||
py from evadventure.utils import get_obj_stats;print(get_obj_stats(self))
|
||||
|
||||
You should get back a nice string about yourself! If that works, great! But you'll need to remember
|
||||
doing that test when you change this code later.
|
||||
|
||||
```{sidebar}
|
||||
In [evennia/contrib/tutorials/evadventure/tests/test_utils.py](evennia.contrib.tutorials.
|
||||
evadventure.tests.test_utils)
|
||||
is an example of the testing module. To dive deeper into unit testing in Evennia, see the
|
||||
[Unit testing](../../../Coding/Unit-Testing.md) documentation.
|
||||
```
|
||||
|
||||
A _unit test_ allows you to set up automated testing of code. Once you've written your test you
|
||||
can run it over and over and make sure later changes to your code didn't break things.
|
||||
|
||||
In this particular case, we _expect_ to later have to update the test when `get_obj_stats` becomes more
|
||||
complete and returns more reasonable data.
|
||||
|
||||
Evennia comes with extensive functionality to help you test your code. Here's a module for
|
||||
testing `get_obj_stats`.
|
||||
|
||||
```python
|
||||
# mygame/evadventure/tests/test_utils.py
|
||||
|
||||
from evennia.utils import create
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
|
||||
from ..import utils
|
||||
|
||||
class TestUtils(BaseEvenniaTest):
|
||||
def test_get_obj_stats(self):
|
||||
# make a simple object to test with
|
||||
obj = create.create_object(
|
||||
key="testobj",
|
||||
attributes=(("desc", "A test object"),)
|
||||
)
|
||||
# run it through the function
|
||||
result = utils.get_obj_stats(obj)
|
||||
# check that the result is what we expected
|
||||
self.assertEqual(
|
||||
result,
|
||||
"""
|
||||
|ctestobj|n
|
||||
Value: ~|y10|n coins
|
||||
|
||||
A test object
|
||||
|
||||
Slots: |w1|n, Used from: |wbackpack|n
|
||||
Quality: |w3|n, Uses: |winfinite|n
|
||||
Attacks using |wstrength|n against |warmor|n
|
||||
Damage roll: |w1d6|n
|
||||
""".strip()
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
What happens here is that we create a new test-class `TestUtils` that inherits from `BaseEvenniaTest`.
|
||||
This inheritance is what makes this a testing class.
|
||||
|
||||
We can have any number of methods on this class. To have a method recognized as one containing
|
||||
code to test, its name _must_ start with `test_`. We have one - `test_get_obj_stats`.
|
||||
|
||||
In this method we create a dummy `obj` and gives it a `key` "testobj". Note how we add the
|
||||
`desc` [Attribute](../../../Components/Attributes.md) directly in the `create_object` call by specifying the attribute as a
|
||||
tuple `(name, value)`!
|
||||
|
||||
We then get the result of passing this dummy-object through `get_obj_stats` we imported earlier.
|
||||
|
||||
The `assertEqual` method is available on all testing classes and checks that the `result` is equal
|
||||
to the string we specify. If they are the same, the test _passes_, otherwise it _fails_ and we
|
||||
need to investigate what went wrong.
|
||||
|
||||
### Running your test
|
||||
|
||||
To run your test you need to stand inside your `mygame` folder and execute the following command:
|
||||
|
||||
evennia test --settings settings.py .evadventure.tests
|
||||
|
||||
This will run all your `evadventure` tests (if you had more of them). To only run your utility tests
|
||||
you could do
|
||||
|
||||
evennia test --settings settings.py .evadventure.tests.test_utils
|
||||
|
||||
If all goes well, you should get an `OK` back. Otherwise you need to check the failure, maybe
|
||||
your return string doesn't quite match what you expected.
|
||||
|
||||
## Summary
|
||||
|
||||
It's very important to understand how you import code between modules in Python, so if this is still
|
||||
confusing to you, it's worth to read up on this more.
|
||||
|
||||
That said, many newcomers are confused with how to begin, so by creating the folder structure, some
|
||||
small modules and even making your first unit test, you are off to a great start!
|
||||
Loading…
Add table
Add a link
Reference in a new issue