From f298de05857e19c05a46a0c6d1808752fc50cb35 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 14 Jul 2022 20:29:09 +0200 Subject: [PATCH] First working attack in tutorial combat system --- .../tutorials/evadventure/characters.py | 55 ++-- .../tutorials/evadventure/combat_turnbased.py | 240 +++++++++++------- evennia/contrib/tutorials/evadventure/npcs.py | 8 + .../contrib/tutorials/evadventure/objects.py | 14 + .../contrib/tutorials/evadventure/rules.py | 44 +++- evennia/objects/objects.py | 28 +- 6 files changed, 260 insertions(+), 129 deletions(-) diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index 36e3933c52..2f0dce2bef 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -9,7 +9,7 @@ from evennia.utils.utils import int2str, lazy_property from . import rules from .enums import Ability, WieldLocation -from .objects import EvAdventureObject +from .objects import EvAdventureObject, WeaponEmptyHand class EquipmentError(TypeError): @@ -134,7 +134,7 @@ class EquipmentHandler: @property def weapon(self): """ - Conveniently get the currently active weapon. + Conveniently get the currently active weapon or rune stone. Returns: obj or None: The weapon. None if unarmored. @@ -146,6 +146,8 @@ class EquipmentHandler: weapon = slots[WieldLocation.TWO_HANDS] if not weapon: weapon = slots[WieldLocation.WEAPON_HAND] + if not weapon: + weapon = WeaponEmptyHand() return weapon def display_loadout(self): @@ -370,6 +372,13 @@ class LivingMixin: else: self.msg(f"|g{healer.key} heals you for {healed} health.|n") + def at_damage(self, damage, attacker=None): + """ + Called when attacked and taking damage. + + """ + pass + class EvAdventureCharacter(LivingMixin, DefaultCharacter): """ @@ -401,23 +410,6 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): """Allows to access equipment like char.equipment.worn""" return EquipmentHandler(self) - @property - def weapon(self): - """ - Quick access to the character's currently wielded weapon. - - """ - self.equipment.weapon - - @property - def armor(self): - """ - Quick access to the character's current armor. - Will return the "Unarmored" armor level (11) if none other are found. - - """ - self.equipment.armor or 11 - def at_pre_object_receive(self, moved_object, source_location, **kwargs): """ Hook called by Evennia before moving an object here. Return False to abort move. @@ -467,21 +459,25 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): """ self.equipment.remove(moved_object) - def at_damage(self, dmg, attacker=None): + def at_defeat(self): """ - Called when receiving damage for whatever reason. This - is called *before* hp is evaluated for defeat/death. + This happens when character drops <= 0 HP. For Characters, this means rolling on + the death table. """ + rules.dice.roll_death(self) + if hp <= 0: + # this means we rolled death on the table + self.handle_death() + else: + # still alive, but lost in some stats + self.location.msg_contents( + f"|y$You() $conj(stagger) back, weakened but still alive.|n", from_obj=self + ) def defeat_message(self, attacker, dmg): - return f"After {attacker.key}'s attack, {self.key} collapses in a heap." - - def at_defeat(self, attacker, dmg): """ - At this point, character has been defeated but is not killed (their - hp >= 0 but they lost ability bonuses). Called after being defeated in combat or - other situation where health is lost below or equal to 0. + Sent out to everyone in the location by the combathandler. """ @@ -490,3 +486,6 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): Called when character dies. """ + self.location.msg_contents( + f"|r$You() $conj(collapse) in a heap. No getting back from that.|n", from_obj=self + ) diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index ddb313c72d..e310757d13 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -104,7 +104,7 @@ from datetime import datetime from evennia.scripts.scripts import DefaultScript from evennia.typeclasses.attributes import AttributeProperty -from evennia.utils import dbserialize, delay, evmenu, evtable +from evennia.utils import dbserialize, delay, evmenu, evtable, logger from evennia.utils.utils import make_iter from . import rules @@ -121,6 +121,11 @@ class CombatFailure(RuntimeError): """ +# ----------------------------------------------------------------------------------- +# Combat Actions +# ----------------------------------------------------------------------------------- + + class CombatAction: """ This is the base of a combat-action, like 'attack' Inherit from this to make new actions. @@ -141,8 +146,6 @@ class CombatAction: # use None to do nothing (jump directly to registering the action) next_menu_node = "node_select_target" - # action to echo to everyone. - post_action_text = "{combatant} performed an action." max_uses = None # None for unlimited # in which order (highest first) to perform the action. If identical, use random order priority = 0 @@ -153,12 +156,15 @@ class CombatAction: self.uses = 0 def msg(self, message, broadcast=False): - if broadcast: - # send to everyone in combat. - self.combathandler.msg(message) - else: - # send only to the combatant. - self.combatant.msg(message) + """ + Convenience route to the combathandler msg-sender mechanism. + + Args: + message (str): Message to send; use `$You()` and `$You(other.key)` + to refer to the combatant doing the action and other combatants, + respectively. + """ + self.combathandler.msg(message, combatant=self.combatant, broadcast=broadcast) def __serialize_dbobjs__(self): """ @@ -207,14 +213,26 @@ class CombatAction: return True if self.max_uses is None else self.uses < (self.max_uses or 0) def pre_use(self, *args, **kwargs): + """ + Called just before the main action. + + """ + pass def use(self, *args, **kwargs): + """ + Main activation of the action. This happens simultaneously to other actions. + + """ pass def post_use(self, *args, **kwargs): - self.uses += 1 - self.combathandler.msg(self.post_action_text.format(**kwargs)) + """ + Called just after the action has been taken. + + """ + pass class CombatActionAttack(CombatAction): @@ -237,27 +255,53 @@ class CombatActionAttack(CombatAction): """ attacker = self.combatant + weapon = self.combatant.equipment.weapon # figure out advantage (gained by previous stunts) advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False)) - # figure out disadvantage (gained by enemy stunts/actions) disadvantage = bool(self.combathandler.disadvantage_matrix[attacker].pop(defender, False)) - is_hit, quality = rules.dice.opposed_saving_throw( + is_hit, quality, txt = rules.dice.opposed_saving_throw( attacker, defender, - attack_type=attacker.weapon.attack_type, - defense_type=attacker.weapon.defense_type, + attack_type=weapon.attack_type, + defense_type=attacker.equipment.weapon.defense_type, advantage=advantage, disadvantage=disadvantage, ) + self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}") if is_hit: - self.combathandler.resolve_damage( - attacker, defender, critical=quality == "critical success" - ) + # enemy hit, calculate damage + weapon_dmg_roll = attacker.equipment.weapon.damage_roll - # TODO messaging here + dmg = rules.dice.roll(weapon_dmg_roll) + + if quality is Ability.CRITICAL_SUCCESS: + dmg += rules.dice.roll(weapon_dmg_roll) + message = ( + f" $You() |ycritically|n $conj(hit) $You({defender.key}) for |r{dmg}|n damage!" + ) + else: + message = f" $You() $conj(hit) $You({defender.key}) for |r{dmg}|n damage!" + self.msg(message) + + defender.hp -= dmg + + # call hook + defender.at_damage(dmg, attacker=attacker) + + # note that we mustn't remove anyone from combat yet, because this is + # happening simultaneously. So checking of the final hp + # and rolling of death etc happens in the combathandler at the end of the turn. + + else: + # a miss + message = f" $You() $conj(miss) $You({defender.key})." + if quality is Ability.CRITICAL_FAILURE: + attacker.equipment.weapon.quality -= 1 + message += ".. it's a |rcritical miss!|n, damaging the weapon." + self.msg(message) class CombatActionStunt(CombatAction): @@ -299,7 +343,7 @@ class CombatActionStunt(CombatAction): attacker = self.combatant advantage, disadvantage = False, False - is_success, _ = rules.dice.opposed_saving_throw( + is_success, _, txt = rules.dice.opposed_saving_throw( attacker, defender, attack_type=self.attack_type, @@ -307,13 +351,13 @@ class CombatActionStunt(CombatAction): advantage=advantage, disadvantage=disadvantage, ) + self.msg(f"$You() $conj(attempt) stunt on $You(defender.key). {txt}") if is_success: if advantage: self.combathandler.gain_advantage(attacker, defender) else: self.combathandler.gain_disadvantage(defender, attacker) - self.msg # only spend a use after being successful self.uses += 1 @@ -376,11 +420,14 @@ class CombatActionFlee(CombatAction): "Disengage from combat. Use successfully two times in a row to leave combat at the " "end of the second round. If someone Blocks you successfully, this counter is reset." ) - priority = -5 # checked last def use(self, *args, **kwargs): # it's safe to do this twice + self.msg( + "$You() retreats, and will leave combat next round unless someone successfully " + "blocks them." + ) self.combathandler.flee(self.combatant) @@ -409,7 +456,7 @@ class CombatActionBlock(CombatAction): advantage = bool(self.advantage_matrix[combatant].pop(fleeing_target, False)) disadvantage = bool(self.disadvantage_matrix[combatant].pop(fleeing_target, False)) - is_success, _ = rules.dice.opposed_saving_throw( + is_success, _, txt = rules.dice.opposed_saving_throw( combatant, fleeing_target, attack_type=self.attack_type, @@ -417,12 +464,14 @@ class CombatActionBlock(CombatAction): advantage=advantage, disadvantage=disadvantage, ) + self.msg(f"$You() tries to block the retreat of $You({fleeing_target.key}). {txt}") if is_success: # managed to stop the target from fleeing/disengaging self.combatant.unflee(fleeing_target) + self.msg("$You() blocks the retreat of $You({fleeing_target.key})") else: - pass # they are getting away! + self.msg("$You({fleeing_target.key}) dodges away from you $You()!") class CombatActionSwapWieldedWeaponOrSpell(CombatAction): @@ -450,8 +499,6 @@ class CombatActionSwapWieldedWeaponOrSpell(CombatAction): next_menu_node = "node_select_wield_from_inventory" - post_action_text = "{combatant} switches weapons." - def use(self, combatant, item, *args, **kwargs): # this will make use of the item combatant.inventory.use(item) @@ -470,10 +517,9 @@ class CombatActionUseItem(CombatAction): next_menu_node = "node_select_use_item_from_inventory" - post_action_text = "{combatant} used an item." - def use(self, combatant, item, *args, **kwargs): item.use(combatant, *args, **kwargs) + self.msg("$You() $conj(use) an item.") class CombatActionDoNothing(CombatAction): @@ -492,6 +538,14 @@ class CombatActionDoNothing(CombatAction): post_action_text = "{combatant} does nothing this turn." + def use(self, *args, **kwargs): + self.msg("$You() $conj(hesitate), accomplishing nothing.") + + +# ----------------------------------------------------------------------------------- +# Combat handler +# ----------------------------------------------------------------------------------- + class EvAdventureCombatHandler(DefaultScript): """ @@ -618,6 +672,8 @@ class EvAdventureCombatHandler(DefaultScript): self.interval - warning_time, self._warn_time, warning_time ) + self.msg(f"|y_______________________ start turn {self.turn} ___________________________|n") + for combatant in self.combatants: # cycle combat menu self._init_menu(combatant) @@ -628,10 +684,15 @@ class EvAdventureCombatHandler(DefaultScript): End of turn operations. 1. Do all regular actions + 2. Roll for any death events 2. Remove combatants that disengaged successfully 3. Timeout advantages/disadvantages """ + self.msg( + f"|y__________________ turn resolution (turn {self.turn}) ____________________|n\n" + ) + # do all actions for combatant in self.combatants: # read the current action type selected by the player @@ -639,7 +700,16 @@ class EvAdventureCombatHandler(DefaultScript): combatant, (CombatActionDoNothing(self, combatant), (), {}) ) # perform the action on the CombatAction instance - action.use(*args, **kwargs) + try: + action.pre_use(*args, **kwargs) + action.use(*args, **kwargs) + action.post_use(*args, **kwargs) + except Exception as err: + combatant.msg( + f"An error ({err}) occurred when performing this action.\n" + "Please report the problem to an admin." + ) + logger.log_trace() # handle disengaging combatants @@ -647,14 +717,37 @@ class EvAdventureCombatHandler(DefaultScript): for combatant in self.combatants: # check disengaging combatants (these are combatants that managed - # to stay at disengaging distance for a turn) + # not get their escape blocked last turn if combatant in self.fleeing_combatants: self.fleeing_combatants.remove(combatant) + if combatant.hp <= 0: + # characters roll on the death table here, npcs usually just die + combatant.at_defeat() + + # tell everyone + self.msg(combatant.defeat_message(attacker, dmg), combatant=combatant) + + if defender.hp > 0: + # death roll didn't kill them - they are weakened, but with hp + self.msg( + "You are alive, but out of the fight. If you want to press your luck, " + "you need to rejoin the combat.", + combatant=combatant, + broadcast=False, + ) + defender.at_defeat() # note - NPC monsters may still 'die' here + else: + # outright killed + defender.at_death() + + # no matter the result, the combatant is out + to_remove.append(combatant) + for combatant in to_remove: # for clarity, we remove here rather than modifying the combatant list # inside the previous loop - self.msg(f"{combatant.key} disengaged and left combat.") + self.msg(f"|y$You() $conj(are) out of combat.|n", combatant=combatant) self.remove_combatant(combatant) # refresh stunt timeouts (note - self.stunt_duration is the same for @@ -788,7 +881,7 @@ class EvAdventureCombatHandler(DefaultScript): if comb is combatant: continue - name = combatant.key + name = comb.key health = f"{comb.hurt_level}" fleeing = "" if comb in self.fleeing_combatants: @@ -798,24 +891,37 @@ class EvAdventureCombatHandler(DefaultScript): return str(table) - def msg(self, message, targets=None): + def msg(self, message, combatant=None, broadcast=True): """ Central place for sending messages to combatants. This allows for adding any combat-specific text-decoration in one place. Args: message (str): The message to send. - targets (Object or list, optional): Sends message only to - one or more particular combatants. If unset, send to - everyone in the combat. + combatant (Object): The 'You' in the message, if any. + broadcast (bool): If `False`, `combatant` must be included and + will be the only one to see the message. If `True`, send to + everyone in the location. + + Notes: + If `combatant` is given, use `$You/you()` markup to create + a message that looks different depending on who sees it. Use + `$You(combatant_key)` to refer to other combatants. """ - if targets: - for target in make_iter(targets): - target.msg(message) - else: - for target in self.combatants: - target.msg(message) + location = self.obj + location_objs = location.contents + + exclude = [] + if not broadcast and combatant: + exclude = [obj for obj in location_objs if obj is not combatant] + + location.msg_contents( + message, + exclude=exclude, + from_obj=combatant, + mapping={locobj.key: locobj for locobj in location_objs}, + ) def gain_advantage(self, combatant, target): """ @@ -839,48 +945,6 @@ class EvAdventureCombatHandler(DefaultScript): if combatant in self.fleeing_combatants: self.fleeing_combatants.remove(combatant) - def resolve_damage(self, attacker, defender, critical=False): - """ - Apply damage to defender. On a critical hit, the damage die - is rolled twice. - - """ - weapon_dmg_roll = attacker.weapon.damage_roll - - dmg = rules.dice.roll(weapon_dmg_roll) - if critical: - dmg += rules.dice.roll(weapon_dmg_roll) - - defender.hp -= dmg - - # call hook - defender.at_damage(dmg, attacker=attacker) - - if defender.hp <= 0: - # roll on death table. This may or may not kill you - rules.dice.roll_death(self) - - # tell everyone - self.msg(defender.defeat_message(attacker, dmg)) - - if defender.hp > 0: - # they are weakened, but with hp - self.msg( - "You are alive, but out of the fight. If you want to press your luck, " - "you need to rejoin the combat.", - targets=defender, - ) - defender.at_defeat() # note - NPC monsters may still 'die' here - else: - # outright killed - defender.at_death() - - # no matter the result, the combatant is out - self.remove_combatant(defender) - else: - # defender still alive - self.msg(defender) - def register_action(self, combatant, action_key, *args, **kwargs): """ Register an action based on its `.key`. @@ -927,7 +991,9 @@ class EvAdventureCombatHandler(DefaultScript): return list(self.combatant_actions[combatant].values()) -# ------------ start combat menu definitions +# ----------------------------------------------------------------------------------- +# Combat Menu definitions +# ----------------------------------------------------------------------------------- def _register_action(caller, raw_string, **kwargs): @@ -1165,7 +1231,9 @@ def node_wait_start(caller, raw_string, **kwargs): return text, options -# -------------- end of combat menu definitions +# ----------------------------------------------------------------------------------- +# Access function +# ----------------------------------------------------------------------------------- def join_combat(caller, *targets, session=None): diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index df4fcb63e6..6b109187b5 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -69,6 +69,14 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter): """ self.hp = self.hp_max + def ai_combat_next_action(self): + """ + The combat engine should ask this method in order to + get the next action the npc should perform in combat. + + """ + pass + class EvAdventureShopKeeper(EvAdventureNPC): """ diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index c35bbd0d90..3b9caf1d6c 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -85,6 +85,20 @@ class EvAdventureWeapon(EvAdventureObject): damage_roll = AttributeProperty("1d6") +class WeaponEmptyHand: + """ + This is used when you wield no weapons. We won't create any db-object for it. + + """ + + 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 ... + + class EvAdventureRunestone(EvAdventureWeapon): """ Base class for magic runestones. In _Knave_, every spell is represented by a rune stone diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index 1a4c06f612..f9e6a05efe 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -81,7 +81,7 @@ class EvAdventureRollEngine: if 0 < diesize > max_diesize: raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)") - # At this point we know we have valid input - roll and all dice together + # At this point we know we have valid input - roll and add dice together return sum(randint(1, diesize) for _ in range(number)) def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False): @@ -98,7 +98,7 @@ class EvAdventureRollEngine: """ if not (advantage or disadvantage) or (advantage and disadvantage): - # normal roll + # normal roll, or advantage cancels disadvantage return self.roll("1d20") elif advantage: return max(self.roll("1d20"), self.roll("1d20")) @@ -129,9 +129,10 @@ class EvAdventureRollEngine: modifier (int, optional): An additional +/- modifier to the roll. Returns: - tuple: (bool, str): If the save was passed or not. The second element is the - quality of the roll - None (normal), "critical fail" and "critical success". - + tuple: A tuple `(bool, str, str)`. The bool indicates if the save was passed or not. + The second element is the quality of the roll - None (normal), + "critical fail" and "critical success". Last element is a text detailing + the roll, for display purposes. Notes: Advantage and disadvantage cancel each other out. @@ -147,7 +148,25 @@ class EvAdventureRollEngine: quality = Ability.CRITICAL_SUCCESS else: quality = None - return (dice_roll + bonus + modifier) > target, quality + result = dice_roll + bonus + modifier > target + + # determine text output + rolltxt = "d20 " + if advantage and disadvantage: + rolltxt = "d20 (advantage canceled by disadvantage)" + elif advantage: + rolltxt = "|g2d20|n (advantage: picking highest) " + elif disadvantage: + rolltxt = "|r2d20|n (disadvantage: picking lowest) " + bontxt = f"(+{bonus})" + modtxt = "" + if modifier: + modtxt = f" + {modifier}" if modifier > 0 else f" - {abs(modifier)}" + qualtxt = f" ({quality.value}!)" if quality else "" + + txt = f"{dice_roll} + {bonus_type.value}{bontxt}{modtxt} -> |w{result}{qualtxt}|n" + + return (dice_roll + bonus + modifier) > target, quality, txt def opposed_saving_throw( self, @@ -174,14 +193,16 @@ class EvAdventureRollEngine: modifier (int): An additional +/- modifier to the roll. Returns: - tuple: (bool, str): If the attack succeed or not. The second element is the - quality of the roll - None (normal), "critical fail" and "critical success". + tuple: (bool, str, str): If the attack succeed or not. The second element is the + quality of the roll - None (normal), "critical fail" and "critical success". Last + element is a text that summarizes the details of the roll. Notes: Advantage and disadvantage cancel each other out. """ - defender_defense = getattr(defender, defense_type.value, 1) + 10 - return self.saving_throw( + + defender_defense = getattr(defender, defense_type.value, 1) + result, quality, txt = self.saving_throw( attacker, bonus_type=attack_type, target=defender_defense, @@ -189,6 +210,9 @@ class EvAdventureRollEngine: disadvantage=disadvantage, modifier=modifier, ) + txt = f"Roll vs {defense_type.value}({defender_defense}):\n{txt}" + + return result, quality, txt def roll_random_table(self, dieroll, table_choices): """ diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 9d7d6488db..21853e42e0 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -21,9 +21,15 @@ from evennia.scripts.scripthandler import ScriptHandler from evennia.typeclasses.attributes import ModelAttributeBackend, NickHandler from evennia.typeclasses.models import TypeclassBase from evennia.utils import ansi, create, funcparser, logger, search -from evennia.utils.utils import (class_from_module, is_iter, lazy_property, - list_to_string, make_iter, to_str, - variable_from_module) +from evennia.utils.utils import ( + class_from_module, + is_iter, + lazy_property, + list_to_string, + make_iter, + to_str, + variable_from_module, +) _INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE @@ -714,7 +720,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): for obj in contents: func(obj, **kwargs) - def msg_contents(self, text=None, exclude=None, from_obj=None, mapping=None, **kwargs): + def msg_contents( + self, + text=None, + exclude=None, + from_obj=None, + mapping=None, + raise_funcparse_errors=False, + **kwargs, + ): """ Emits a message to all objects inside this object. @@ -738,6 +752,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): in the `text` string. If `` doesn't have a `get_display_name` method, it will be returned as a string. If not set, a key `you` will be auto-added to point to `from_obj` if given, otherwise to `self`. + raise_funcparse_errors (bool, optional): If set, a failing `$func()` will + lead to an outright error. If unset (default), the failing `$func()` + will instead appear in output unparsed. + **kwargs: Keyword arguments will be passed on to `obj.msg()` for all messaged objects. @@ -802,7 +820,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # actor-stance replacements inmessage = _MSG_CONTENTS_PARSER.parse( inmessage, - raise_errors=True, + raise_errors=raise_funcparse_errors, return_string=True, caller=you, receiver=receiver,