diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 63abb2f713..52e63b7b1a 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -50,6 +50,9 @@ things you want from here into your game folder and change them there. time to pass depending on if you are walking/running etc. * Talking NPC (Griatch 2011) - A talking NPC object that offers a menu-driven conversation tree. +* Tree Select (FlutterSprite 2017) - A simple system for creating a + branching EvMenu with selection options sourced from a single + multi-line string. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 79a61ce65d..e2148fa0da 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1206,6 +1206,36 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +# Test tree select + +from evennia.contrib import tree_select + +TREE_MENU_TESTSTR = """Foo +Bar +-Baz +--Baz 1 +--Baz 2 +-Qux""" + +class TestTreeSelectFunc(EvenniaTest): + + def test_tree_functions(self): + # Dash counter + self.assertTrue(tree_select.dashcount("--test") == 2) + # Is category + self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True) + # Parse options + self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")]) + # Index to selection + self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz") + # Go up one category + self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2) + # Option list to menu options + test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) + optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] + self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) # Test of the unixcommand module diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py new file mode 100644 index 0000000000..5b8f038e33 --- /dev/null +++ b/evennia/contrib/tree_select.py @@ -0,0 +1,535 @@ +""" +Easy menu selection tree + +Contrib - Tim Ashley Jenkins 2017 + +This module allows you to create and initialize an entire branching EvMenu +instance with nothing but a multi-line string passed to one function. + +EvMenu is incredibly powerful and flexible, but using it for simple menus +can often be fairly cumbersome - a simple menu that can branch into five +categories would require six nodes, each with options represented as a list +of dictionaries. + +This module provides a function, init_tree_selection, which acts as a frontend +for EvMenu, dynamically sourcing the options from a multi-line string you provide. +For example, if you define a string as such: + + TEST_MENU = '''Foo + Bar + Baz + Qux''' + +And then use TEST_MENU as the 'treestr' source when you call init_tree_selection +on a player: + + init_tree_selection(TEST_MENU, caller, callback) + +The player will be presented with an EvMenu, like so: + + ___________________________ + + Make your selection: + ___________________________ + + Foo + Bar + Baz + Qux + +Making a selection will pass the selection's key to the specified callback as a +string along with the caller, as well as the index of the selection (the line number +on the source string) along with the source string for the tree itself. + +In addition to specifying selections on the menu, you can also specify categories. +Categories are indicated by putting options below it preceded with a '-' character. +If a selection is a category, then choosing it will bring up a new menu node, prompting +the player to select between those options, or to go back to the previous menu. In +addition, categories are marked by default with a '[+]' at the end of their key. Both +this marker and the option to go back can be disabled. + +Categories can be nested in other categories as well - just go another '-' deeper. You +can do this as many times as you like. There's no hard limit to the number of +categories you can go down. + +For example, let's add some more options to our menu, turning 'Bar' into a category. + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz + Qux''' + +Now when we call the menu, we can see that 'Bar' has become a category instead of a +selectable option. + + _______________________________ + + Make your selection: + _______________________________ + + Foo + Bar [+] + Baz + Qux + +Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it. + + ________________________________________________________________ + + Bar + ________________________________________________________________ + + You've got to know [+] + << Go Back: Return to the previous menu. + +Just the one option, which is a category itself, and the option to go back, which will +take us back to the previous menu. Let's select 'You've got to know'. + + ________________________________________________________________ + + You've got to know + ________________________________________________________________ + + When to hold em + When to fold em + When to walk away + << Go Back: Return to the previous menu. + +Now we see the three options listed under it, too. We can select one of them or use 'Go +Back' to return to the 'Bar' menu we were just at before. It's very simple to make a +branching tree of selections! + +One last thing - you can set the descriptions for the various options simply by adding a +':' character followed by the description to the option's line. For example, let's add a +description to 'Baz' in our menu: + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz: Look at this one: the best option. + Qux''' + +Now we see that the Baz option has a description attached that's separate from its key: + + _______________________________________________________________ + + Make your selection: + _______________________________________________________________ + + Foo + Bar [+] + Baz: Look at this one: the best option. + Qux + +Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call +your specified callback with the selection, like so: + + callback(caller, TEST_MENU, 0, "Foo") + +The index of the selection is given along with a string containing the selection's key. +That way, if you have two selections in the menu with the same key, you can still +differentiate between them. + +And that's all there is to it! For simple branching-tree selections, using this system is +much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic +options much easier - since the source of the menu tree is just a string, you could easily +generate that string procedurally before passing it to the init_tree_selection function. +For example, if a player casts a spell or does an attack without specifying a target, instead +of giving them an error, you could present them with a list of valid targets to select by +generating a multi-line string of targets and passing it to init_tree_selection, with the +callable performing the maneuver once a selection is made. + +This selection system only works for simple branching trees - doing anything really complicated +like jumping between categories or prompting for arbitrary input would still require a full +EvMenu implementation. For simple selections, however, I'm sure you will find using this function +to be much easier! + +Included in this module is a sample menu and function which will let a player change the color +of their name - feel free to mess with it to get a feel for how this system works by importing +this module in your game's default_cmdsets.py module and adding CmdNameColor to your default +character's command set. +""" + +from evennia.utils import evmenu +from evennia.utils.logger import log_trace +from evennia import Command + +def init_tree_selection(treestr, caller, callback, + index=None, mark_category=True, go_back=True, + cmd_on_exit="look", + start_text="Make your selection:"): + """ + Prompts a player to select an option from a menu tree given as a multi-line string. + + Args: + treestr (str): Multi-lne string representing menu options + caller (obj): Player to initialize the menu for + callback (callable): Function to run when a selection is made. Must take 4 args: + caller (obj): Caller given above + treestr (str): Menu tree string given above + index (int): Index of final selection + selection (str): Key of final selection + + Options: + index (int or None): Index to start the menu at, or None for top level + mark_category (bool): If True, marks categories with a [+] symbol in the menu + go_back (bool): If True, present an option to go back to previous categories + start_text (str): Text to display at the top level of the menu + cmd_on_exit(str): Command to enter when the menu exits - 'look' by default + + + Notes: + This function will initialize an instance of EvMenu with options generated + dynamically from the source string, and passes the menu user's selection to + a function of your choosing. The EvMenu is made of a single, repeating node, + which will call itself over and over at different levels of the menu tree as + categories are selected. + + Once a non-category selection is made, the user's selection will be passed to + the given callable, both as a string and as an index number. The index is given + to ensure every selection has a unique identifier, so that selections with the + same key in different categories can be distinguished between. + + The menus called by this function are not persistent and cannot perform + complicated tasks like prompt for arbitrary input or jump multiple category + levels at once - you'll have to use EvMenu itself if you want to take full + advantage of its features. + """ + + # Pass kwargs to store data needed in the menu + kwargs = { + "index":index, + "mark_category":mark_category, + "go_back":go_back, + "treestr":treestr, + "callback":callback, + "start_text":start_text + } + + # Initialize menu of selections + evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect", + startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs) + +def dashcount(entry): + """ + Counts the number of dashes at the beginning of a string. This + is needed to determine the depth of options in categories. + + Args: + entry (str): String to count the dashes at the start of + + Returns: + dashes (int): Number of dashes at the start + """ + dashes = 0 + for char in entry: + if char == "-": + dashes += 1 + else: + return dashes + return dashes + +def is_category(treestr, index): + """ + Determines whether an option in a tree string is a category by + whether or not there are additional options below it. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Which line of the string to test + + Returns: + is_category (bool): Whether the option is a category + """ + opt_list = treestr.split('\n') + # Not a category if it's the last one in the list + if index == len(opt_list) - 1: + return False + # Not a category if next option is not one level deeper + return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1) + +def parse_opts(treestr, category_index=None): + """ + Parses a tree string and given index into a list of options. If + category_index is none, returns all the options at the top level of + the menu. If category_index corresponds to a category, returns a list + of options under that category. If category_index corresponds to + an option that is not a category, it's a selection and returns True. + + Args: + treestr (str): Multi-line string representing menu options + category_index (int): Index of category or None for top level + + Returns: + kept_opts (list or True): Either a list of options in the selected + category or True if a selection was made + """ + dash_depth = 0 + opt_list = treestr.split('\n') + kept_opts = [] + + # If a category index is given + if category_index != None: + # If given index is not a category, it's a selection - return True. + if not is_category(treestr, category_index): + return True + # Otherwise, change the dash depth to match the new category. + dash_depth = dashcount(opt_list[category_index]) + 1 + # Delete everything before the category index + opt_list = opt_list [category_index+1:] + + # Keep every option (referenced by index) at the appropriate depth + cur_index = 0 + for option in opt_list: + if dashcount(option) == dash_depth: + if category_index == None: + kept_opts.append((cur_index, option[dash_depth:])) + else: + kept_opts.append((cur_index + category_index + 1, option[dash_depth:])) + # Exits the loop if leaving a category + if dashcount(option) < dash_depth: + return kept_opts + cur_index += 1 + return kept_opts + +def index_to_selection(treestr, index, desc=False): + """ + Given a menu tree string and an index, returns the corresponding selection's + name as a string. If 'desc' is set to True, will return the selection's + description as a string instead. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to convert to selection key or description + + Options: + desc (bool): If true, returns description instead of key + + Returns: + selection (str): Selection key or description if 'desc' is set + """ + opt_list = treestr.split('\n') + # Fetch the given line + selection = opt_list[index] + # Strip out the dashes at the start + selection = selection[dashcount(selection):] + # Separate out description, if any + if ":" in selection: + # Split string into key and description + selection = selection.split(':', 1) + selection[1] = selection[1].strip(" ") + else: + # If no description given, set description to None + selection = [selection, None] + if not desc: + return selection[0] + else: + return selection[1] + +def go_up_one_category(treestr, index): + """ + Given a menu tree string and an index, returns the category that the given option + belongs to. Used for the 'go back' option. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to determine the parent category of + + Returns: + parent_category (int): Index of parent category + """ + opt_list = treestr.split('\n') + # Get the number of dashes deep the given index is + dash_level = dashcount(opt_list[index]) + # Delete everything after the current index + opt_list = opt_list[:index+1] + + + # If there's no dash, return 'None' to return to base menu + if dash_level == 0: + return None + current_index = index + # Go up through each option until we find one that's one category above + for selection in reversed(opt_list): + if dashcount(selection) == dash_level - 1: + return current_index + current_index -= 1 + +def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): + """ + Takes a list of options processed by parse_opts and turns it into + a list/dictionary of menu options for use in menunode_treeselect. + + Args: + treestr (str): Multi-line string representing menu options + optlist (list): List of options to convert to EvMenu's option format + index (int): Index of current category + mark_category (bool): Whether or not to mark categories with [+] + go_back (bool): Whether or not to add an option to go back in the menu + + Returns: + menuoptions (list of dicts): List of menu options formatted for use + in EvMenu, each passing a different "newindex" kwarg that changes + the menu level or makes a selection + """ + + menuoptions = [] + cur_index = 0 + for option in optlist: + index_to_add = optlist[cur_index][0] + menuitem = {} + keystr = index_to_selection(treestr, index_to_add) + if mark_category and is_category(treestr, index_to_add): + # Add the [+] to the key if marking categories, and the key by itself as an alias + menuitem["key"] = [keystr + " [+]", keystr] + else: + menuitem["key"] = keystr + # Get the option's description + desc = index_to_selection(treestr, index_to_add, desc=True) + if desc: + menuitem["desc"] = desc + # Passing 'newindex' as a kwarg to the node is how we move through the menu! + menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}] + menuoptions.append(menuitem) + cur_index += 1 + # Add option to go back, if needed + if index != None and go_back == True: + gobackitem = {"key":["<< Go Back", "go back", "back"], + "desc":"Return to the previous menu.", + "goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]} + menuoptions.append(gobackitem) + return menuoptions + +def menunode_treeselect(caller, raw_string, **kwargs): + """ + This is the repeating menu node that handles the tree selection. + """ + + # If 'newindex' is in the kwargs, change the stored index. + if "newindex" in kwargs: + caller.ndb._menutree.index = kwargs["newindex"] + + # Retrieve menu info + index = caller.ndb._menutree.index + mark_category = caller.ndb._menutree.mark_category + go_back = caller.ndb._menutree.go_back + treestr = caller.ndb._menutree.treestr + callback = caller.ndb._menutree.callback + start_text = caller.ndb._menutree.start_text + + # List of options if index is 'None' or category, or 'True' if a selection + optlist = parse_opts(treestr, category_index=index) + + # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu. + if optlist == True: + selection = index_to_selection(treestr, index) + try: + callback(caller, treestr, index, selection) + except Exception: + log_trace("Error in tree selection callback.") + + # Returning None, None ends the menu. + return None, None + + # Otherwise, convert optlist to a list of menu options. + else: + options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back) + if index == None: + # Use start_text for the menu text on the top level + text = start_text + else: + # Use the category name and description (if any) as the menu text + if index_to_selection(treestr, index, desc=True) != None: + text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True) + else: + text = "|w" + index_to_selection(treestr, index) + "|n" + return text, options + +# The rest of this module is for the example menu and command! It'll change the color of your name. + +""" +Here's an example string that you can initialize a menu from. Note the dashes at +the beginning of each line - that's how menu option depth and hierarchy is determined. +""" + +NAMECOLOR_MENU = """Set name color: Choose a color for your name! +-Red shades: Various shades of |511red|n +--Red: |511Set your name to Red|n +--Pink: |533Set your name to Pink|n +--Maroon: |301Set your name to Maroon|n +-Orange shades: Various shades of |531orange|n +--Orange: |531Set your name to Orange|n +--Brown: |321Set your name to Brown|n +--Sienna: |420Set your name to Sienna|n +-Yellow shades: Various shades of |551yellow|n +--Yellow: |551Set your name to Yellow|n +--Gold: |540Set your name to Gold|n +--Dandelion: |553Set your name to Dandelion|n +-Green shades: Various shades of |141green|n +--Green: |141Set your name to Green|n +--Lime: |350Set your name to Lime|n +--Forest: |032Set your name to Forest|n +-Blue shades: Various shades of |115blue|n +--Blue: |115Set your name to Blue|n +--Cyan: |155Set your name to Cyan|n +--Navy: |113Set your name to Navy|n +-Purple shades: Various shades of |415purple|n +--Purple: |415Set your name to Purple|n +--Lavender: |535Set your name to Lavender|n +--Fuchsia: |503Set your name to Fuchsia|n +Remove name color: Remove your name color, if any""" + +class CmdNameColor(Command): + """ + Set or remove a special color on your name. Just an example for the + easy menu selection tree contrib. + """ + + key = "namecolor" + + def func(self): + # This is all you have to do to initialize a menu! + init_tree_selection(NAMECOLOR_MENU, self.caller, + change_name_color, + start_text="Name color options:") + +def change_name_color(caller, treestr, index, selection): + """ + Changes a player's name color. + + Args: + caller (obj): Character whose name to color. + treestr (str): String for the color change menu - unused + index (int): Index of menu selection - unused + selection (str): Selection made from the name color menu - used + to determine the color the player chose. + """ + + # Store the caller's uncolored name + if not caller.db.uncolored_name: + caller.db.uncolored_name = caller.key + + # Dictionary matching color selection names to color codes + colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301", + "Orange":"|531", "Brown":"|321", "Sienna":"|420", + "Yellow":"|551", "Gold":"|540", "Dandelion":"|553", + "Green":"|141", "Lime":"|350", "Forest":"|032", + "Blue":"|115", "Cyan":"|155", "Navy":"|113", + "Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"} + + # I know this probably isn't the best way to do this. It's just an example! + if selection == "Remove name color": # Player chose to remove their name color + caller.key = caller.db.uncolored_name + caller.msg("Name color removed.") + elif selection in colordict: + newcolor = colordict[selection] # Retrieve color code based on menu selection + caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name + caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n") +