11. Turnbased Combat¶
In this lesson we will be building on the combat base to implement a combat system that works in turns and where you select your actions in a menu, like this:
> attack Troll
______________________________________________________________________________
You (Perfect) vs Troll (Perfect)
Your queued action: [attack] (22s until next round,
or until all combatants have chosen their next action).
______________________________________________________________________________
1: attack an enemy
2: Stunt - gain a later advantage against a target
3: Stunt - give an enemy disadvantage against yourself or an ally
4: Use an item on yourself or an ally
5: Use an item on an enemy
6: Wield/swap with an item from inventory
7: flee!
8: hold, doing nothing
> 4
_______________________________________________________________________________
Select the item
_______________________________________________________________________________
1: Potion of Strength
2. Potion of Dexterity
3. Green Apple
4. Throwing Daggers
back
abort
> 1
_______________________________________________________________________________
Choose an ally to target.
_______________________________________________________________________________
1: Yourself
back
abort
> 1
_______________________________________________________________________________
You (Perfect) vs Troll (Perfect)
Your queued action: [use] (6s until next round,
or until all combatants have chosen their next action).
_______________________________________________________________________________
1: attack an enemy
2: Stunt - gain a later advantage against a target
3: Stunt - give an enemy disadvantage against yourself or an ally
4: Use an item on yourself or an ally
5: Use an item on an enemy
6: Wield/swap with an item from inventory
7: flee!
8: hold, doing nothing
Troll attacks You with Claws: Roll vs armor (12):
rolled 4 on d20 + strength(+3) vs 12 -> Fail
Troll missed you.
You use Potion of Strength.
Renewed strength coarses through your body!
Potion of Strength was used up.
Note that this documentation doesn’t show in-game colors. Also, if you interested in an alternative, see the previous lesson where we implemented a ‘twitch’-like combat system based on entering direct commands for every action.
With ‘turnbased’ combat, we mean combat that ‘ticks’ along at a slower pace, slow enough to allow the participants to select their options in a menu (the menu is not strictly necessary, but it’s a good way to learn how to make menus as well). Their actions are queued and will be executed when the turn timer runs out. To avoid waiting unnecessarily, we will also move on to the next round whenever everyone has made their choices.
The advantage of a turnbased system is that it removes player speed from the equation; your prowess in combat does not depend on how quickly you can enter a command. For RPG-heavy games you could also allow players time to make RP emotes during the rounds of combat to flesh out the action.
The advantage of using a menu is that you have all possible actions directly available to you, making it beginner friendly and easy to know what you can do. It also means a lot less writing which can be an advantage to some players.
11.1. General Principle¶
Here is the general principle of the Turnbased combat handler:
The turnbased version of the CombatHandler will be stored on the current location. That means that there will only be one combat per location. Anyone else starting combat will join the same handler and be assigned a side to fight on.
The handler will run a central timer of 30s (in this example). When it fires, all queued actions will be executed. If everyone has submitted their actions, this will happen immediately when the last one submits.
While in combat you will not be able to move around - you are stuck in the room. Fleeing combat is a separate action that takes a few turns to complete (we will need to create this).
Starting the combat is done via the
attack <target>
command. After that you are in the combat menu and will use the menu for all subsequent actions.
11.2. Turnbased combat handler¶
Create a new module
evadventure/combat_turnbased.py
.
# in evadventure/combat_turnbased.py
from .combat_base import (
CombatActionAttack,
CombatActionHold,
CombatActionStunt,
CombatActionUseItem,
CombatActionWield,
EvAdventureCombatBaseHandler,
)
from .combat_base import EvAdventureCombatBaseHandler
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
action_classes = {
"hold": CombatActionHold,
"attack": CombatActionAttack,
"stunt": CombatActionStunt,
"use": CombatActionUseItem,
"wield": CombatActionWield,
"flee": None # we will add this soon!
}
# fallback action if not selecting anything
fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False)
# track which turn we are on
turn = AttributeProperty(0)
# who is involved in combat, and their queued action
# as {combatant: actiondict, ...}
combatants = AttributeProperty(dict)
# who has advantage against whom. This is a structure
# like {"combatant": {enemy1: True, enemy2: True}}
advantage_matrix = AttributeProperty(defaultdict(dict))
# same for disadvantages
disadvantage_matrix = AttributeProperty(defaultdict(dict))
# how many turns you must be fleeing before escaping
flee_timeout = AttributeProperty(1, autocreate=False)
# track who is fleeing as {combatant: turn_they_started_fleeing}
fleeing_combatants = AttributeProperty(dict)
# list of who has been defeated so far
defeated_combatants = AttributeProperty(list)
We leave a placeholder for the "flee"
action since we haven’t created it yet.
Since the turnbased combat handler is shared between all combatants, we need to store references to those combatants on the handler, in the combatants
Attribute. In the same way we must store a matrix of who has advantage/disadvantage against whom. We must also track who is fleeing, in particular how long they have been fleeing, since they will be leaving combat after that time.
11.2.1. Getting the sides of combat¶
The two sides are different depending on if we are in an PvP room or not: In a PvP room everyone else is your enemy. Otherwise only NPCs in combat is your enemy (you are assumed to be teaming up with your fellow players).
# in evadventure/combat_turnbased.py
# ...
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
# ...
def get_sides(self, combatant):
"""
Get a listing of the two 'sides' of this combat,
m the perspective of the provided combatant.
"""
if self.obj.allow_pvp:
# in pvp, everyone else is an ememy
allies = [combatant]
enemies = [comb for comb in self.combatants if comb != combatant]
else:
# otherwise, enemies/allies depend on who combatant is
pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)]
npcs = [comb for comb in self.combatants if comb not in pcs]
if combatant in pcs:
# combatant is a PC, so NPCs are all enemies
allies = pcs
enemies = npcs
else:
# combatant is an NPC, so PCs are all enemies
allies = npcs
enemies = pcs
return allies, enemies
Note that since the EvadventureCombatBaseHandler
(which our turnbased handler is based on) is a Script, it provides many useful features. For example self.obj
is the entity on which this Script ‘sits’. Since we are planning to put this handler on the current location, then self.obj
will be that Room.
All we do here is check if it’s a PvP room or not and use this to figure out who would be an ally or an enemy. Note that the combatant
is not included in the allies
return - we’ll need to remember this.
11.2.2. Tracking Advantage/Disadvantage¶
# in evadventure/combat_turnbased.py
# ...
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
# ...
def give_advantage(self, combatant, target):
self.advantage_matrix[combatant][target] = True
def give_disadvantage(self, combatant, target, **kwargs):
self.disadvantage_matrix[combatant][target] = True
def has_advantage(self, combatant, target, **kwargs):
return (
target in self.fleeing_combatants
or bool(self.advantage_matrix[combatant].pop(target, False))
)
def has_disadvantage(self, combatant, target):
return bool(self.disadvantage_matrix[combatant].pop(target, False))
We use the advantage/disadvantage_matrix
Attributes to track who has advantage against whom.
In the has dis/advantage
methods we pop
the target from the matrix which will result either in the value True
or False
(the default value we give to pop
if the target is not in the matrix). This means that the advantage, once gained, can only be used once.
We also consider everyone to have advantage against fleeing combatants.
11.2.3. Adding and removing combatants¶
Since the combat handler is shared we must be able to add- and remove combatants easily. This is new compared to the base handler.
# in evadventure/combat_turnbased.py
# ...
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
# ...
def add_combatant(self, combatant):
"""
Add a new combatant to the battle. Can be called multiple times safely.
"""
if combatant not in self.combatants:
self.combatants[combatant] = self.fallback_action_dict
return True
return False
def remove_combatant(self, combatant):
"""
Remove a combatant from the battle.
"""
self.combatants.pop(combatant, None)
# clean up menu if it exists
# TODO!
We simply add the the combatant with the fallback action-dict to start with. We return a bool
from add_combatant
so that the calling function will know if they were actually added anew or not (we may want to do some extra setup if they are new).
For now we just pop
the combatant, but in the future we’ll need to do some extra cleanup of the menu when combat ends (we’ll get to that).
11.2.4. Flee Action¶
Since you can’t just move away from the room to flee turnbased combat, we need to add a new CombatAction
subclass like the ones we created in the base combat lesson.
# in evadventure/combat_turnbased.py
from .combat_base import CombatAction
# ...
class CombatActionFlee(CombatAction):
"""
Start (or continue) fleeing/disengaging from combat.
action_dict = {
"key": "flee",
}
"""
def execute(self):
combathandler = self.combathandler
if self.combatant not in combathandler.fleeing_combatants:
# we record the turn on which we started fleeing
combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn
# show how many turns until successful flight
current_turn = combathandler.turn
started_fleeing = combathandler.fleeing_combatants[self.combatant]
flee_timeout = combathandler.flee_timeout
time_left = flee_timeout - (current_turn - started_fleeing) - 1
if time_left > 0:
self.msg(
"$You() $conj(retreat), being exposed to attack while doing so (will escape in "
f"{time_left} $pluralize(turn, {time_left}))."
)
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
action_classes = {
"hold": CombatActionHold,
"attack": CombatActionAttack,
"stunt": CombatActionStunt,
"use": CombatActionUseItem,
"wield": CombatActionWield,
"flee": CombatActionFlee # < ---- added!
}
# ...
We create the action to make use of the fleeing_combatants
dict we set up in the combat handler. This dict stores the fleeing combatant along with the turn
its fleeing started. If performing the flee
action multiple times, we will just display how many turns are remaining.
Finally, we make sure to add our new CombatActionFlee
to the action_classes
registry on the combat handler.
11.2.5. Queue action¶
# in evadventure/combat_turnbased.py
# ...
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
# ...
def queue_action(self, combatant, action_dict):
self.combatants[combatant] = action_dict
# track who inserted actions this turn (non-persistent)
did_action = set(self.ndb.did_action or set())
did_action.add(combatant)
if len(did_action) >= len(self.combatants):
# everyone has inserted an action. Start next turn without waiting!
self.force_repeat()
To queue an action, we simply store its action_dict
with the combatant in the combatants
Attribute.
We use a Python set()
to track who has queued an action this turn. If all combatants have entered a new (or renewed) action this turn, we use the .force_repeat()
method, which is available on all Scripts. When this is called, the next round will fire immediately instead of waiting until it times out.
11.2.6. Execute an action and tick the round¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | # in evadventure/combat_turnbased.py import random # ... class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler): # ... def execute_next_action(self, combatant): # this gets the next dict and rotates the queue action_dict = self.combatants.get(combatant, self.fallback_action_dict) # use the action-dict to select and create an action from an action class action_class = self.action_classes[action_dict["key"]] action = action_class(self, combatant, action_dict) action.execute() action.post_execute() if action_dict.get("repeat", False): # queue the action again *without updating the # *.ndb.did_action list* (otherwise # we'd always auto-end the turn if everyone used # repeating actions and there'd be # no time to change it before the next round) self.combatants[combatant] = action_dict else: # if not a repeat, set the fallback action self.combatants[combatant] = self.fallback_action_dict def at_repeat(self): """ This method is called every time Script repeats (every `interval` seconds). Performs a full turn of combat, performing everyone's actions in random order. """ self.turn += 1 # random turn order combatants = list(self.combatants.keys()) random.shuffle(combatants) # shuffles in place # do everyone's next queued combat action for combatant in combatants: self.execute_next_action(combatant) self.ndb.did_action = set() # check if one side won the battle self.check_stop_combat() |
Our action-execution consists of two parts - the execute_next_action
(which was defined in the parent class for us to implement) and the at_repeat
method which is a part of the Script
For execute_next_action
:
Line 13: We get the
action_dict
from thecombatants
Attribute. We return thefallback_action_dict
if nothing was queued (this defaults tohold
).Line 16: We use the
key
of theaction_dict
(which would be something like “attack”, “use”, “wield” etc) to get the class of the matching Action from theaction_classes
dictionary.Line 17: Here the action class is instantiated with the combatant and action dict, making it ready to execute. This is then executed on the following lines.
Line 22: We introduce a new optional
action-dict
here, the booleanrepeat
key. This allows us to re-queue the action. If not the fallback action will be used.
The at_repeat
is called repeatedly every interval
seconds that the Script fires. This is what we use to track when each round ends.
Lines 43: In this example, we have no internal order between actions. So we simply randomize in which order they fire.
Line 49: This
set
was assigned to in thequeue_action
method to know when everyone submitted a new action. We must make sure to unset it here before the next round.
11.2.7. Check and stop combat¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | # in evadventure/combat_turnbased.py import random from evennia.utils.utils import list_to_string # ... class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler): # ... def stop_combat(self): """ Stop the combat immediately. """ for combatant in self.combatants: self.remove_combatant(combatant) self.stop() self.delete() def check_stop_combat(self): """Check if it's time to stop combat""" # check if anyone is defeated for combatant in list(self.combatants.keys()): if combatant.hp <= 0: # PCs roll on the death table here, NPCs die. # Even if PCs survive, they # are still out of the fight. combatant.at_defeat() self.combatants.pop(combatant) self.defeated_combatants.append(combatant) self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant) else: self.combatants[combatant] = self.fallback_action_dict # check if anyone managed to flee flee_timeout = self.flee_timeout for combatant, started_fleeing in self.fleeing_combatants.items(): if self.turn - started_fleeing >= flee_timeout - 1: # if they are still alive/fleeing and have been fleeing long enough, escape self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant) self.remove_combatant(combatant) # check if one side won the battle if not self.combatants: # noone left in combat - maybe they killed each other or all fled surviving_combatant = None allies, enemies = (), () else: # grab a random survivor and check of they have any living enemies. surviving_combatant = random.choice(list(self.combatants.keys())) allies, enemies = self.get_sides(surviving_combatant) if not enemies: # if one way or another, there are no more enemies to fight still_standing = list_to_string(f"$You({comb.key})" for comb in allies) knocked_out = list_to_string(comb for comb in self.defeated_combatants if comb.hp > 0) killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0) if still_standing: txt = [f"The combat is over. {still_standing} are still standing."] else: txt = ["The combat is over. No-one stands as the victor."] if knocked_out: txt.append(f"{knocked_out} were taken down, but will live.") if killed: txt.append(f"{killed} were killed.") self.msg(txt) self.stop_combat() |
The check_stop_combat
is called at the end of the round. We want to figure out who is dead and if one of the ‘sides’ won.
Lines 28-38: We go over all combatants and determine if they are out of HP. If so we fire the relevant hooks and add them to the
defeated_combatants
Attribute.Line 38: For all surviving combatants, we make sure give them the
fallback_action_dict
.Lines 41-46: The
fleeing_combatant
Attribute is a dict on the form{fleeing_combatant: turn_number}
, tracking when they first started fleeing. We compare this with the current turn number and theflee_timeout
to see if they now flee and should be allowed to be removed from combat.Lines 49-56: Here on we are determining if one ‘side’ of the conflict has defeated the other side.
Line 60: The
list_to_string
Evennia utility converts a list of entries, like["a", "b", "c"
to a nice string"a, b and c"
. We use this to be able to present some nice ending messages to the combatants.
11.2.8. Start combat¶
Since we are using the timer-component of the Script to tick our combat, we also need a helper method to ‘start’ that.
from evennia.utils.utils import list_to_string
# in evadventure/combat_turnbased.py
# ...
class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
# ...
def start_combat(self, **kwargs):
"""
This actually starts the combat. It's safe to run this multiple times
since it will only start combat if it isn't already running.
"""
if not self.is_active:
self.start(**kwargs)
The start(**kwargs)
method is a method on the Script, and will make it start to call at_repeat
every interval
seconds. We will pass that interval
inside kwargs
(so for example, we’ll do combathandler.start_combat(interval=30)
later).
11.5. Attack Command¶
We will only need one single Command to run the Turnbased combat system. This is the attack
command. Once you use it once, you will be in the menu.
# in evadventure/combat_turnbased.py
from evennia import Command, CmdSet, EvMenu
# ...
class CmdTurnAttack(Command):
"""
Start or join combat.
Usage:
attack [<target>]
"""
key = "attack"
aliases = ["hit", "turnbased combat"]
turn_timeout = 30 # seconds
flee_time = 3 # rounds
def parse(self):
super().parse()
self.args = self.args.strip()
def func(self):
if not self.args:
self.msg("What are you attacking?")
return
target = self.caller.search(self.args)
if not target:
return
if not hasattr(target, "hp"):
self.msg("You can't attack that.")
return
elif target.hp <= 0:
self.msg(f"{target.get_display_name(self.caller)} is already down.")
return
if target.is_pc and not target.location.allow_pvp:
self.msg("PvP combat is not allowed here!")
return
combathandler = _get_combathandler(
self.caller, self.turn_timeout, self.flee_time)
# add combatants to combathandler. this can be done safely over and over
combathandler.add_combatant(self.caller)
combathandler.queue_action(self.caller, {"key": "attack", "target": target})
combathandler.add_combatant(target)
target.msg("|rYou are attacked by {self.caller.get_display_name(self.caller)}!|n")
combathandler.start_combat()
# build and start the menu
EvMenu(
self.caller,
{
"node_choose_enemy_target": node_choose_enemy_target,
"node_choose_allied_target": node_choose_allied_target,
"node_choose_enemy_recipient": node_choose_enemy_recipient,
"node_choose_allied_recipient": node_choose_allied_recipient,
"node_choose_ability": node_choose_ability,
"node_choose_use_item": node_choose_use_item,
"node_choose_wield_item": node_choose_wield_item,
"node_combat": node_combat,
},
startnode="node_combat",
combathandler=combathandler,
auto_look=False,
# cmdset_mergetype="Union",
persistent=True,
)
class TurnCombatCmdSet(CmdSet):
"""
CmdSet for the turn-based combat.
"""
def at_cmdset_creation(self):
self.add(CmdTurnAttack())
The attack target
Command will determine if the target has health (only things with health can be attacked) and that the room allows fighting. If the target is a pc, it will check so PvP is allowed.
It then proceeds to either start up a new command handler or reuse a new one, while adding the attacker and target to it. If the target was already in combat, this does nothing (same with the .start_combat()
call).
As we create the EvMenu
, we pass it the “menu index” we talked to about earlier, now with the actual node functions in every slot. We make the menu persistent so it survives a reload.
To make the command available, add the TurnCombatCmdSet
to the Character’s default cmdset.
11.7. Testing¶
Unit testing of the Turnbased combat handler is straight forward, you follow the process of earlier lessons to test that each method on the handler returns what you expect with mocked inputs.
Unit-testing the menu is more complex. You will find examples of doing this in evennia.utils.tests.test_evmenu.
11.8. A small combat test¶
Unit testing the code is not enough to see that combat works. We need to also make a little ‘functional’ test to see how it works in practice.
This is what we need for a minimal test:
A room with combat enabled.
An NPC to attack (it won’t do anything back yet since we haven’t added any AI)
A weapon we can
wield
.An item (like a potion) we can
use
.
In The Twitch combat lesson we used a batch-command script to create the testing environment in game. This runs in-game Evennia commands in sequence. For demonstration purposes we’ll instead use a batch-code script, which runs raw Python code in a repeatable way. A batch-code script is much more flexible than a batch-command script.
Create a new subfolder
evadventure/batchscripts/
(if it doesn’t already exist)
Create a new Python module
evadventure/batchscripts/combat_demo.py
A batchcode file is a valid Python module. The only difference is that it has a # HEADER
block and one or more # CODE
sections. When the processor runs, the # HEADER
part will be added on top of each # CODE
part before executing that code block in isolation. Since you can run the file from in-game (including refresh it without reloading the server), this gives the ability to run longer Python codes on-demand.
# Evadventure (Turnbased) combat demo - using a batch-code file.
#
# Sets up a combat area for testing turnbased combat.
#
# First add mygame/server/conf/settings.py:
#
# BASE_BATCHPROCESS_PATHS += ["evadventure.batchscripts"]
#
# Run from in-game as `batchcode turnbased_combat_demo`
#
# HEADER
from evennia import DefaultExit, create_object, search_object
from evennia.contrib.tutorials.evadventure.characters import EvAdventureCharacter
from evennia.contrib.tutorials.evadventure.combat_turnbased import TurnCombatCmdSet
from evennia.contrib.tutorials.evadventure.npcs import EvAdventureNPC
from evennia.contrib.tutorials.evadventure.rooms import EvAdventureRoom
# CODE
# Make the player an EvAdventureCharacter
player = caller # caller is injected by the batchcode runner, it's the one running this script # E: undefined name 'caller'
player.swap_typeclass(EvAdventureCharacter)
# add the Turnbased cmdset
player.cmdset.add(TurnCombatCmdSet, persistent=True)
# create a weapon and an item to use
create_object(
"contrib.tutorials.evadventure.objects.EvAdventureWeapon",
key="Sword",
location=player,
attributes=[("desc", "A sword.")],
)
create_object(
"contrib.tutorials.evadventure.objects.EvAdventureConsumable",
key="Potion",
location=player,
attributes=[("desc", "A potion.")],
)
# start from limbo
limbo = search_object("#2")[0]
arena = create_object(EvAdventureRoom, key="Arena", attributes=[("desc", "A large arena.")])
# Create the exits
arena_exit = create_object(DefaultExit, key="Arena", location=limbo, destination=arena)
back_exit = create_object(DefaultExit, key="Back", location=arena, destination=limbo)
# create the NPC dummy
create_object(
EvAdventureNPC,
key="Dummy",
location=arena,
attributes=[("desc", "A training dummy."), ("hp", 1000), ("hp_max", 1000)],
)
If editing this in an IDE, you may get errors on the player = caller
line. This is because caller
is not defined anywhere in this file. Instead caller
(the one running the script) is injected by the batchcode
runner.
But apart from the # HEADER
and # CODE
specials, this just a series of normal Evennia api calls.
Log into the game with a developer/superuser account and run
> batchcmd evadventure.batchscripts.turnbased_combat_demo
This should place you in the arena with the dummy (if not, check for errors in the output! Use objects
and delete
commands to list and delete objects if you need to start over.)
You can now try attack dummy
and should be able to pound away at the dummy (lower its health to test destroying it). If you need to fix something, use q
to exit the menu and get access to the reload
command (for the final combat, you can disable this ability by passing auto_quit=False
when you create the EvMenu
).
11.9. Conclusions¶
At this point we have coverered some ideas on how to implement both twitch- and turnbased combat systems. Along the way you have been exposed to many concepts such as classes, scripts and handlers, Commands, EvMenus and more.
Before our combat system is actually usable, we need our enemies to actually fight back. We’ll get to that next.