From e7f8926b233d00a2d7fd043a936b6587a70855bc Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 11 Jul 2022 20:45:30 +0200 Subject: [PATCH] Add more parts of the turnbased combat tutorial --- .../tutorials/evadventure/characters.py | 84 +++++- .../tutorials/evadventure/combat_turnbased.py | 239 ++++++++++++++---- .../contrib/tutorials/evadventure/commands.py | 58 +++++ .../contrib/tutorials/evadventure/objects.py | 52 +++- .../contrib/tutorials/evadventure/rooms.py | 12 + .../tutorials/evadventure/tests/mixins.py | 5 +- evennia/scripts/scripthandler.py | 19 +- 7 files changed, 399 insertions(+), 70 deletions(-) diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index 3c2d4a5ea5..e3fdd0801c 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -19,7 +19,7 @@ class EquipmentHandler: """ _Knave_ puts a lot of emphasis on the inventory. You have CON_DEFENSE inventory slots. Some things, like torches can fit multiple in one slot, other (like - big weapons) use more than one slot. The items carried and wielded has a big impact + big weapons and armor) use more than one slot. The items carried and wielded has a big impact on character customization - even magic requires carrying a runestone per spell. The inventory also doubles as a measure of negative effects. Getting soaked in mud @@ -147,6 +147,43 @@ class EquipmentHandler: weapon = slots[WieldLocation.WEAPON_HAND] return weapon + def display_loadout(self): + """ + Get a visual representation of your current loadout. + + Returns: + str: The current loadout. + + """ + slots = self.slots + one_hand = None + weapon_str = "You are fighting with your bare fists" + shield_str = " and have no shield." + armor_str = "You wear no armor" + helmet_str = " and no helmet." + + two_hands = slots[WieldLocation.TWO_HANDS] + if two_hands: + weapon_str = f"You wield {two_hands} with both hands" + shield_str = f" (you can't hold a shield at the same time)." + else: + one_hands = slots[WieldLocation.WEAPON_HAND] + if one_hands: + weapon_str = f"You are wielding {one_hands} in one hand." + shield = slots[WieldLocation.SHIELD_HAND] + if shield: + shield_str = f"You have {shield} in your off hand." + + armor = slots[WieldLocation.BODY] + if armor: + armor_str = f"You are wearing {armor}" + + helmet = slots[WieldLocation.BODY] + if helmet: + helmet_str = f" and {helmet} on your head." + + return f"{weapon_str}{shield_str}\n{armor_str}{helmet_str}" + def use(self, obj): """ Make use of item - this makes use of the object's wield slot to decide where @@ -242,6 +279,51 @@ class EquipmentHandler: self._save() return ret + def get_wieldable_objects_from_backpack(self): + """ + Get all wieldable weapons (or spell runes) from backpack. This is useful in order to + have a list to select from when swapping your wielded loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [obj for obj in slots[WieldLocation.BACKPACK] + if obj.inventory_use_slot in ( + WieldLocation.WEAPON_HAND, + WieldLocation.TWO_HANDS, + WieldLocation.SHIELD_HAND)] + + def get_wearable_objects_from_backpack(self): + """ + Get all wearable items (armor or helmets) from backpack. This is useful in order to + have a list to select from when swapping your worn loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [obj for obj in slots[WieldLocation.BACKPACK] + if obj.inventory_use_slot in ( + WieldLocation.BODY, + WieldLocation.HEAD + )] + + def get_usable_objects_from_backpack(self): + """ + Get all 'usable' items (like potions) from backpack. This is useful for getting a + list to select from. + + Returns: + list: A list of objects that are usable. + + """ + return [obj for obj in slots[WieldLocation.BACKPACK] if obj.uses > 0] + class LivingMixin: """ diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 62b7ffbc95..81016b65bb 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -108,7 +108,9 @@ from evennia.utils import evtable, dbserialize, delay, evmenu from .enums import Ability from . import rules -# for simplicity, we have a default duration for advantages/disadvantages + +COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler" +COMBAT_HANDLER_INTERVAL = 60 class CombatFailure(RuntimeError): @@ -134,8 +136,9 @@ class CombatAction: aliases = [] help_text = "Combat action to perform." - # if no target is needed (always affect oneself) - no_target = False + # the next combat menu node to go to - this ties the combat action into the UI + # 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." @@ -199,7 +202,7 @@ class CombatAction: if available, should describe what the action does. """ - return True if self.uses is None else self.uses < self.max_uses + return True if self.uses is None else self.uses < (self.max_uses or 0) def pre_use(self, *args, **kwargs): pass @@ -364,7 +367,7 @@ class CombatActionFlee(CombatAction): aliases = ("d", "disengage", "flee") # this only affects us - no_target = True + next_menu_node = "node_register_action" help_text = ( "Disengage from combat. Use successfully two times in a row to leave combat at the " @@ -419,6 +422,45 @@ class CombatActionBlock(CombatAction): pass # they are getting away! +class CombatActionSwapWieldedWeaponOrSpell(CombatAction): + """ + Swap Wielded weapon or spell. + + """ + key = "Swap weapon/rune/shield" + desc = "Swap currently wielded weapon, shield or spell-rune." + aliases = ("s", "swap", "draw", "swap weapon", "draw weapon", + "swap rune", "draw rune", "swap spell", "draw spell") + help_text = ("Draw a new weapon or spell-rune from your inventory, " + "replacing your current loadout") + + 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) + + +class CombatActionUseItem(CombatAction): + """ + Use an item from inventory. + + """ + key = "Use an item from backpack" + desc = "Use an item from your inventory." + aliases = ("u", "use", "use item") + help_text = "Choose an item from your inventory to use." + + 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) + + class CombatActionDoNothing(CombatAction): """ Do nothing this turn. @@ -431,7 +473,7 @@ class CombatActionDoNothing(CombatAction): help_text = "Hold you position, doing nothing." # affects noone else - no_target = True + next_menu_node = "node_register_action" post_action_text = "{combatant} does nothing this turn." @@ -439,7 +481,9 @@ class CombatActionDoNothing(CombatAction): class EvAdventureCombatHandler(DefaultScript): """ This script is created when combat is initialized and stores a queue - of all active participants. It's also possible to join (or leave) the fray later. + of all active participants. + + It's also possible to join (or leave) the fray later. """ @@ -450,6 +494,7 @@ class EvAdventureCombatHandler(DefaultScript): default_action_classes = [ CombatActionAttack, CombatActionStunt, + CombatActionSwapWieldedWeaponOrSpell, CombatActionUseItem, CombatActionFlee, CombatActionBlock, @@ -481,7 +526,8 @@ class EvAdventureCombatHandler(DefaultScript): def at_script_creation(self): # how often this script ticks - the max length of each turn (in seconds) - self.interval = 60 + self.key = COMBAT_HANDLER_KEY + self.interval = COMBAT_HANDLER_INTERVAL def at_repeat(self, **kwargs): """ @@ -621,6 +667,7 @@ class EvAdventureCombatHandler(DefaultScript): """ if combatant not in self.combatants: self.combatants.append(combatant) + combatant.db.turnbased_combathandler = self # allow custom character actions (not used by default) custom_action_classes = combatant.db.custom_combat_actions or [] @@ -637,13 +684,14 @@ class EvAdventureCombatHandler(DefaultScript): { "node_wait_start": node_wait_start, "node_select_target": node_select_target, - "node_selct_action": node_select_action, + "node_select_action": node_select_action, "node_wait_turn": node_wait_turn, }, startnode="node_wait_turn", auto_quit=False, persistent=True, session=session, + combathandler=self # makes this available as combatant.ndb._evmenu.combathandler ) def remove_combatant(self, combatant): @@ -658,6 +706,7 @@ class EvAdventureCombatHandler(DefaultScript): self.combatants.remove(combatant) self.combatant_actions.pop(combatant, None) combatant.ndb._evmenu.close_menu() + del combatant.db.turnbased_combathandler def start_combat(self): """ @@ -866,7 +915,7 @@ def _register_action(caller, raw_string, **kwargs): action_key = kwargs.get["action_key"] action_args = kwargs["action_args"] action_kwargs = kwargs["action_kwargs"] - action_target = kwargs["action_target"] + action_target = kwargs.get("action_target") combat_handler = caller._evmenu.combathandler combat_handler.register_action( caller, action_key, action_target, *action_args, **action_kwargs) @@ -884,44 +933,128 @@ def node_select_target(caller, raw_string, **kwargs): action_key = kwargs.get("action_key") action_args = kwargs.get("action_args") action_kwargs = kwargs.get("action_kwargs") - combat = caller.scripts.get("combathandler") + combat = caller.ndb._evmenu.combathandler text = "Select target for |w{action_key}|n." # make the apply-self option always the first one, give it key 0 + kwargs["action_target"] = caller options = [ { "key": "0", "desc": "(yourself)", - "goto": ( - _register_action, - { - "action_key": action_key, - "action_args": action_args, - "action_kwargs": action_kwargs, - "action_target": caller, - }, - ), + "goto": (_register_action, kwargs) } ] # filter out ourselves and then make options for everyone else combatants = [combatant for combatant in combat.combatants if combatant is not caller] for combatant in combatants: # automatic menu numbering starts from 1 + kwargs["action_target"] = combatant options.append( { "desc": combatant.key, - "goto": ( - _register_action, - { - "action_key": action_key, - "action_args": action_args, - "action_kwargs": action_kwargs, - "action_target": combatant, - }, - ), + "goto": (_register_action, kwargs) } ) + # add ability to cancel + options.append( + { + "key": "_default", + "desc": "(No input to Abort and go back)", + "goto": "node_select_action" + } + ) + + return text, options + + +def _item_broken(caller, raw_string, **kwargs): + caller.msg("|rThis item is broken and unusable!|n") + return None # back to previous node + + +def node_select_wield_from_inventory(caller, raw_string, **kwargs): + """ + Menu node allowing for wielding item(s) from inventory. + + """ + combat = caller.ndb._evmenu.combathandler + loadout = caller.inventory.display_loadout() + text = (f"{loadout}\nSelect weapon, spell or shield to draw. It will swap out " + "anything already in the same hand (you can't change armor or helmet in combat).") + + # get a list of all suitable weapons/spells/shields + options = [] + for obj in caller.inventory.get_wieldable_objects_from_backpack(): + if obj.quality <= 0: + # object is broken + options.append( + { + "desc": f"|Rstr(obj)|n", + "goto": _item_broken, + } + ) + else: + # normally working item + kwargs['action_args'] = (obj,) + options.append( + { + "desc": str(obj), + "goto": (_register_action, kwargs) + } + ) + + # add ability to cancel + options.append( + { + "key": "_default", + "desc": "(No input to Abort and go back)", + "goto": "node_select_action" + } + ) + + return text, options + + +def node_select_use_item_from_inventory(caller, raw_string, **kwargs): + """ + Menu item allowing for using usable items (like potions) from inventory. + + """ + combat = caller.ndb._evmenu.combathandler + text = "Select an item to use." + + # get a list of all suitable weapons/spells/shields + options = [] + for obj in caller.inventory.get_usable_objects_from_backpack(): + if obj.quality <= 0: + # object is broken + options.append( + { + "desc": f"|Rstr(obj)|n", + "goto": _item_broken, + } + ) + else: + # normally working item + kwargs['action_args'] = (obj,) + options.append( + { + "desc": str(obj), + "goto": (_register_action, kwargs) + } + ) + + # add ability to cancel + options.append( + { + "key": "_default", + "desc": "(No input to Abort and go back)", + "goto": "node_select_action" + } + ) + return text, options @@ -941,8 +1074,8 @@ def node_select_action(caller, raw_string, **kwargs): Menu node for selecting a combat action. """ - combat = caller.scripts.get("combathandler") - text = combat.get_previous_turn_status(caller) + combat = caller.ndb._evmenu.combathandler + text = combat.get_combat_summary(caller) options = [] for icount, action in enumerate(combat.get_available_actions(caller)): @@ -968,9 +1101,9 @@ def node_select_action(caller, raw_string, **kwargs): ) } ) - elif action.no_target: - # action is available, and requires no target. Redirect to register - # without going via the select-target node. + elif action.next_menu_node is None: + # action is available, but needs no intermediary step. Redirect to register + # the action immediately options.append( { "key": key, @@ -987,13 +1120,13 @@ def node_select_action(caller, raw_string, **kwargs): } ) else: - # action is available and requires a target, so we will select a target next. + # action is available and next_menu_node is set to point to the next node we want options.append( { "key": key, "desc": desc, "goto": ( - "node_select_target", + action.next_menu_node, { "action_key": action.key, "action_args": (), @@ -1002,6 +1135,14 @@ def node_select_action(caller, raw_string, **kwargs): ), } ) + # add ability to cancel + options.append( + { + "key": "_default", + "desc": "(No input to Abort and go back)", + "goto": "node_select_action" + } + ) return text, options @@ -1044,9 +1185,12 @@ def node_wait_start(caller, raw_string, **kwargs): # -------------- end of combat menu definitions -def join_combat(caller, *targets, combathandler=None, session=None): +def join_combat(caller, *targets, session=None): """ - Join or create a new combat involving caller and at least one target, + Join or create a new combat involving caller and at least one target. The combat + is started on the current room location - this means there can only be one combat + in each room (this is not hardcoded in the combat per-se, but it makes sense for + this implementation). Args: caller (Object): The one starting the combat. @@ -1055,9 +1199,6 @@ def join_combat(caller, *targets, combathandler=None, session=None): one opponent!). Keyword Args: - combathandler (EvAdventureCombatHandler): If not given, a new combat will be created and - at least one `*targets` argument must be provided. If given, caller will - join an existing combat. session (Session, optional): A player session to use. This is useful for multisession modes. Returns: @@ -1065,15 +1206,19 @@ def join_combat(caller, *targets, combathandler=None, session=None): """ created = False + location = caller.location + if not location: + raise CombatFailure("Must have a location to start combat.") + + if not targets: + raise CombatFailure("Must have an opponent to start combat.") + + combathandler = location.scripts.get(COMBAT_HANDLER_KEY).first() if not combathandler: - if not targets: - raise CombatFailure("Must have an opponent to start combat.") - combathandler, _ = EvAdventureCombatHandler.create( - f"Combat_{datetime.utcnow()}", - autostart=False, # means we must use .start() to start the script - ) + combathandler = location.scripts.add(EvAdventureCombatHandler, autostart=False) created = True + # it's safe to add a combatant to the same combat more than once combathandler.add_combatant(caller, session=session) for target in targets: combathandler.add_combatant(target, session=session) diff --git a/evennia/contrib/tutorials/evadventure/commands.py b/evennia/contrib/tutorials/evadventure/commands.py index e69de29bb2..affde6daf6 100644 --- a/evennia/contrib/tutorials/evadventure/commands.py +++ b/evennia/contrib/tutorials/evadventure/commands.py @@ -0,0 +1,58 @@ +""" +EvAdventure commands and cmdsets. + + +""" + +from evennia import Command, default_cmds +from . combat_turnbased import join_combat + + +class EvAdventureCommand(Command): + """ + Base EvAdventure command. This is on the form + + command + + where whitespace around the argument(s) are stripped. + + """ + def parse(self): + self.args = self.args.strip() + + +class CmdAttackTurnBased(EvAdventureCommand): + """ + Attack a target or join an existing combat. + + Usage: + attack + attack , , ... + + If the target is involved in combat already, you'll join combat with + the first target you specify. Attacking multiple will draw them all into + combat. + + This will start/join turn-based, combat, where you have a limited + time to decide on your next action from a menu of options. + + """ + + def parse(self): + super().parse() + self.targets = [name.strip() for name in self.args.split(",")] + + def func(self): + + # find if + + target_objs = [] + for target in self.targets: + target_obj = self.caller.search(target) + if target_obj: + # show a warning but don't abort + continue + target_objs.append(target_obj) + + if target_objs: + join_combat(self.caller, *target_objs, session=self.session) diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index b08ffe1e54..f1f532c282 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -20,13 +20,16 @@ class EvAdventureObject(DefaultObject): """ # inventory management - inventory_use_slot = AttributeProperty(default=WieldLocation.BACKPACK) + inventory_use_slot = AttributeProperty(WieldLocation.BACKPACK) # how many inventory slots it uses (can be a fraction) - size = AttributeProperty(default=1) - armor = AttributeProperty(default=0) + size = AttributeProperty(1) + armor = AttributeProperty(0) + # items that are usable (like potions) have a value larger than 0. Wieldable items + # like weapons, armor etc are not 'usable' in this respect. + uses = AttributeProperty(0) # when 0, item is destroyed and is unusable - quality = AttributeProperty(default=1) - value = AttributeProperty(default=0) + quality = AttributeProperty(1) + value = AttributeProperty(0) class EvAdventureObjectFiller(EvAdventureObject): @@ -41,8 +44,30 @@ class EvAdventureObjectFiller(EvAdventureObject): meaning it's unusable. """ + quality = AttributeProperty(0) - quality = AttributeProperty(default=0) + +class EvAdventureConsumable(EvAdventureObject): + """ + Item that can be 'used up', like a potion or food. Weapons, armor etc does not + have a limited usage in this way. + + """ + inventory_use_slot = AttributeProperty(WieldLocation.BACKPACK) + size = AttributeProperty(0.25) + uses = AttributeProperty(1) + + def use(self, user, *args, **kwargs): + """ + Consume a 'use' of this item. Once it reaches 0 uses, it should normally + not be usable anymore and probably be deleted. + + Args: + user (Object): The one using the item. + *args, **kwargs: Extra arguments depending on the usage and item. + + """ + pass class EvAdventureWeapon(EvAdventureObject): @@ -53,13 +78,9 @@ class EvAdventureWeapon(EvAdventureObject): inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND) - attack_type = AttributeProperty(default=Ability.STR) - defense_type = AttributeProperty(default=Ability.ARMOR) - damage_roll = AttributeProperty(default="1d6") - - # at which ranges this weapon can be used. If not listed, unable to use - distance_optimal = AttributeProperty(default=0) # normal usage (fists) - distance_suboptimal = AttributeProperty(default=None) # disadvantage (fists) + attack_type = AttributeProperty(Ability.STR) + defense_type = AttributeProperty(Ability.ARMOR) + damage_roll = AttributeProperty("1d6") class EvAdventureRunestone(EvAdventureWeapon): @@ -70,3 +91,8 @@ class EvAdventureRunestone(EvAdventureWeapon): they are quite powerful (and scales with caster level). """ + inventory_use_slot = AttributeProperty(WieldLocation.TWO_HANDS) + + attack_type = AttributeProperty(Ability.INT) + defense_type = AttributeProperty(Ability.CON) + damage_roll = AttributeProperty("1d8") diff --git a/evennia/contrib/tutorials/evadventure/rooms.py b/evennia/contrib/tutorials/evadventure/rooms.py index e69de29bb2..eeef8d1893 100644 --- a/evennia/contrib/tutorials/evadventure/rooms.py +++ b/evennia/contrib/tutorials/evadventure/rooms.py @@ -0,0 +1,12 @@ +""" +EvAdventure rooms. + + + +""" + +from evennia import DefaultRoom + + +class EvAdventureRoom(DefaultRoom): + pass diff --git a/evennia/contrib/tutorials/evadventure/tests/mixins.py b/evennia/contrib/tutorials/evadventure/tests/mixins.py index 04746a1734..7a23d27eb2 100644 --- a/evennia/contrib/tutorials/evadventure/tests/mixins.py +++ b/evennia/contrib/tutorials/evadventure/tests/mixins.py @@ -6,6 +6,7 @@ Helpers for testing evadventure modules. from evennia.utils import create from ..characters import EvAdventureCharacter from ..objects import EvAdventureObject +from ..rooms import EvAdventureRoom from .. import enums @@ -17,7 +18,9 @@ class EvAdventureMixin: def setUp(self): super().setUp() - self.character = create.create_object(EvAdventureCharacter, key="testchar") + self.location = create.create_object(EvAdventureRoom, key="testroom") + self.character = create.create_object(EvAdventureCharacter, key="testchar", + location=self.location) self.helmet = create.create_object( EvAdventureObject, key="helmet", diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index e5be10fdfd..905a1b952f 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -69,6 +69,9 @@ class ScriptHandler(object): in script definition and listings) autostart (bool, optional): Start the script upon adding it. + Returns: + Script: The newly created Script. + """ if self.obj.__dbclass__.__name__ == "AccountDB": # we add to an Account, not an Object @@ -76,21 +79,21 @@ class ScriptHandler(object): scriptclass, key=key, account=self.obj, autostart=autostart ) else: - # the normal - adding to an Object. We wait to autostart so we can differentiate + # adding to an Object. We wait to autostart so we can differentiate # a failing creation from a script that immediately starts/stops. script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) if not script: - logger.log_err("Script %s failed to be created/started." % scriptclass) - return False + logger.log_err(f"Script {scriptclass} failed to be created.") + return None if autostart: script.start() if not script.id: # this can happen if the script has repeats=1 or calls stop() in at_repeat. logger.log_info( - "Script %s started and then immediately stopped; " - "it could probably be a normal function." % scriptclass + f"Script {scriptclass} started and then immediately stopped; " + "it could probably be a normal function." ) - return True + return script def start(self, key): """ @@ -118,10 +121,10 @@ class ScriptHandler(object): key (str): Search criterion, the script's key or dbref. Returns: - scripts (list): The found scripts matching `key`. + scripts (queryset): The found scripts matching `key`. """ - return list(ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key)) + return ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key) def delete(self, key=None): """