├── LICENSE ├── README.md └── WhiskersParser.gd /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 LittleMouse Games 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 2 | ![Version](https://img.shields.io/badge/version-1.1.0-orange.svg) 3 | ![Godot Version](https://img.shields.io/badge/godot-3.1-brightgreen.svg) 4 | ![Status](https://img.shields.io/badge/status-beta-blue.svg) 5 | 6 | ## How to use 7 | First of all, this solution is not an _addon_ yet, it's a pure gdscript _class_ i.e., doesn't extend any _Node_. I'll show it's work-flow. 8 | 9 | ### Constructor 10 | ```python 11 | var parser = WhiskersParser.new(default_base_instance) 12 | ``` 13 | 14 | Returns an instance of the parser. The argument `default_base_instance` is an `Object` that will be used as context for expressions execution. 15 | 16 | ### Opening a Whiskers .json file 17 | This class has a static function that opens an exported Whiskers file, it just opens the file, parse the JSON content with `parse_json()` function and return the data. The function is static because it should not have access to any variable or method from the instance. 18 | 19 | ```python 20 | var dialogue_data = parser.open_whiskers(file_path) 21 | ``` 22 | 23 | ### Starting a dialogue 24 | ```python 25 | var block = parser.start_dialogue(dialogue_data, custom_base_instance) 26 | ``` 27 | 28 | The argument `custom_base_instance` sets a temporary base instance valid for the duration of this dialogue and it is optional considering you set a default base instance at the constructor. 29 | 30 | Now it gets interesting. The `sart_dialogue` method returns a **block**, that is a `Dictionary` containing the content of a whiskers node, and every other node that it connects to, separated by category. An example **block** could be: 31 | 32 | ```python 33 | { 34 | key = "Dialogue", 35 | text = "Do you like parties?", 36 | options = [ 37 | {key = "Option", text = "Yes."}, 38 | {key = "Option567", text = "No."}], 39 | expressions = [], 40 | dialogue = {}. 41 | condition = {}, 42 | jump = {}, 43 | is_final = false 44 | } 45 | ``` 46 | 47 | Note that _options_ and _expressions_ are `Array` types, but `dialogue`, `condition` and `jump` are `Dictionary` types. That's because it is allowed only one of these per **block**. 48 | 49 | ### Navigating the dialogue 50 | The method to proceed the dialogue is named `next` and it receives as argument the chosen option key. If there is no option then we call it without arguments. Either way, it will return the next **block** or an empty `Dictionary` if the dialogue ended. 51 | 52 | The example **block** has two options, so to chose one we have to call: 53 | 54 | ```python 55 | block = parser.next(block.options[0].key) 56 | # For "Yes." or 57 | block = parser.next(block.options[1].key) 58 | # For "No." 59 | ``` 60 | 61 | I suggest creating `Button` nodes named by the options keys, and then using the names as argument. It is as simple as: 62 | 63 | ```python 64 | for option in block.options: 65 | add_button(option) 66 | # The add_button method has to be implemented by the user since it depends on the project 67 | ``` 68 | 69 | If you have a block like this: 70 | 71 | ```python 72 | { 73 | key = "Dialogue", 74 | text = "I have something to tell you...", 75 | options = [], 76 | expressions = [], 77 | dialogue = {key = "Dialogue728"}. 78 | condition = {}, 79 | jump = {}, 80 | is_final = false 81 | } 82 | ``` 83 | 84 | Calling `next` will create a **block** from the _Dialogue728_ node. So it allows a `Dialogue` node to be connected to another `Dialogue` node. 85 | 86 | ### Dealing with blocks 87 | The blocks that are returned by `start_dialogue` and `next` are usually `Dialogue` types. That means you will just have to show the text and create the buttons if there are any. The `Expression`, `Condition` and `Jump` types are dealt with automatically by the **WhiskersParser**. 88 | 89 | You can even use `String.format` method as described in the [Godot documentation](https://docs.godotengine.org/en/3.0/getting_started/scripting/gdscript/gdscript_format_string.html#format-method-examples) setting the `format_dictionary` variable like: 90 | 91 | ```python 92 | parser.set_format_dictionary({"player_name" : "Godot-chan"}) 93 | ``` 94 | 95 | And then any string **{player_name}** found in the dialogue texts will be replaced with **Godot-chan**. Note that I didn't say anything about **bbcode**, that's because it is a `RichTextLabel` method that's used out of this class. 96 | 97 | ## Considerations 98 | I think it is really easy to use and the **block** abstraction worked well. I've already tested it with a bunch of dialogues. I didn't do anything about the `data.player_name` variable yet, but it is still there in `parser.data.player_name`. Also, any weird data or whiskers file and the class will print an **[ERROR]** or **[WARNING]** message on the terminal and return an empty `Dictionary`. 99 | -------------------------------------------------------------------------------- /WhiskersParser.gd: -------------------------------------------------------------------------------- 1 | class_name WhiskersParser 2 | 3 | var data : Dictionary 4 | var current_block : Dictionary 5 | var format_dictionary : Dictionary = {} setget set_format_dictionary 6 | var default_base_instance : Object # Default base instance defined at _init method 7 | var base_instance : Object # Object used as a base instance when running expressions 8 | 9 | func _init(base_instance : Object = null): 10 | default_base_instance = base_instance 11 | 12 | if not base_instance: 13 | print("[WARN]: no base_instance for calling expressions.") 14 | 15 | static func open_whiskers(file_path : String) -> Dictionary: 16 | var file = File.new() 17 | 18 | var error = file.open(file_path, File.READ) 19 | if error: 20 | print("[ERROR]: couldn't open file at %s. Error number %s." % [file_path, error]) 21 | return {} 22 | 23 | var dialogue_data = parse_json(file.get_as_text()) 24 | file.close() 25 | 26 | if not dialogue_data is Dictionary: 27 | print("[ERROR]: failed to parse whiskers file. Is it a valid exported whiskers file?") 28 | return {} 29 | 30 | return dialogue_data 31 | 32 | static func parse_whiskers(data : Dictionary) -> Dictionary: 33 | return data 34 | 35 | func start_dialogue(dialogue_data : Dictionary, custom_base_instance : Object = null) -> Dictionary: 36 | if not dialogue_data.has("Start"): 37 | print("[ERROR]: not a valid whiskers data, it has not the key Start.") 38 | return {} 39 | 40 | base_instance = custom_base_instance if custom_base_instance else default_base_instance 41 | data = dialogue_data 42 | current_block = generate_block(data.Start.connects_to.front()) 43 | 44 | return current_block 45 | 46 | func end_dialogue() -> void: 47 | data = {} 48 | current_block = {} 49 | base_instance = default_base_instance 50 | 51 | func next(selected_option_key : String = "") -> Dictionary: 52 | if not data: 53 | print("[WARN]: trying to call next() on a finalized dialogue.") 54 | return {} 55 | 56 | if current_block.is_final: 57 | # It is a final block, but it could be connected to more than an END node, we have to process them 58 | process_block(current_block) 59 | end_dialogue() 60 | return {} 61 | 62 | var next_block = {} 63 | 64 | handle_expressions(current_block.expressions) 65 | 66 | # DEALING WITH OPTIONS 67 | if selected_option_key: 68 | # Generate a block containing all the nodes that this options is connected with 69 | var option_block = generate_block(selected_option_key) 70 | if option_block.empty(): return {} 71 | 72 | next_block = process_block(option_block) 73 | 74 | elif not current_block.options.empty(): 75 | print("[WARN]: no option was passed as argument, but there was options available. This could cause an infinite loop. Use wisely.") 76 | 77 | else: 78 | next_block = process_block(current_block) 79 | 80 | current_block = next_block 81 | 82 | return current_block 83 | 84 | func process_block(block : Dictionary) -> Dictionary: 85 | var next_block = {} 86 | 87 | handle_expressions(block.expressions) 88 | 89 | if not block.dialogue.empty(): 90 | next_block = generate_block(block.dialogue.key) 91 | elif not block.jump.empty(): 92 | next_block = handle_jump(block.jump) 93 | elif not block.condition.empty(): 94 | next_block = handle_condition(block.condition) 95 | 96 | return next_block 97 | 98 | func handle_expressions(expressions_array : Array) -> Array: 99 | if expressions_array.empty(): return [] 100 | 101 | var results = [] 102 | var expression = Expression.new() 103 | 104 | for dic in expressions_array: 105 | results.append(execute_expression(dic.logic)) 106 | 107 | return results 108 | 109 | func handle_condition(condition : Dictionary) -> Dictionary: 110 | var result = execute_expression(condition.logic) 111 | var next_block = {} 112 | 113 | if not result is bool: 114 | print("[ERROR]: the expression used as input for a condition node should return a boolean, but it is returning %s instead." % result) 115 | return {} 116 | 117 | if result: 118 | if not "End" in condition.goes_to_key.if_true: # If a condition node goest to an end node, then we have to end the dialogue 119 | next_block = generate_block(condition.goes_to_key.if_true) 120 | else: 121 | if not "End" in condition.goes_to_key.if_false: 122 | next_block = generate_block(condition.goes_to_key.if_false) 123 | 124 | return next_block 125 | 126 | func handle_jump(jump) -> Dictionary: 127 | # Get the matching node to wich we are going 128 | var jumped_to = generate_block(jump.goes_to_key) 129 | var next_block = {} 130 | 131 | # If this node has expressions that it is connected to, than we want to execute them 132 | handle_expressions(jumped_to.expressions) 133 | 134 | if not jumped_to.dialogue.empty(): 135 | next_block = generate_block(jumped_to.dialogue.key) 136 | elif not jumped_to.jump.empty(): 137 | next_block = handle_jump(jumped_to.jump) 138 | elif not jumped_to.condition.empty(): 139 | next_block = handle_condition(jumped_to.condition) 140 | elif not jumped_to.options.empty(): 141 | next_block = jumped_to 142 | 143 | return next_block 144 | 145 | func execute_expression(expression_text : String): 146 | var expression = Expression.new() 147 | var result = null 148 | 149 | var error = expression.parse(expression_text) 150 | if error: 151 | print("[ERROR]: unable to parse expression %s. Error: %s." % [expression_text, error]) 152 | else: 153 | result = expression.execute([], base_instance, true) 154 | if expression.has_execute_failed(): 155 | print("[ERROR]: unable to execute expression %s." % expression_text) 156 | 157 | return result 158 | 159 | # A block is a Dictionary containing a node and every node it is connected to, by type and it's informations. 160 | func generate_block(node_key : String) -> Dictionary: 161 | if not data.has(node_key): 162 | print("[ERROR]: trying to create block from inexisting node %s. Aborting.", node_key) 163 | return {} 164 | 165 | # Block template 166 | var block = { 167 | key = node_key, 168 | options = [], # key, text 169 | expressions = [], # key, logic 170 | dialogue = {}, # key, text 171 | condition = {}, # key, logic, goes_to_key["true"], goes_to_key["false"] 172 | jump = {}, # key, id, goes_to_key 173 | is_final = false 174 | } 175 | 176 | if "Dialogue" in node_key: 177 | block.text = data[node_key].text.format(format_dictionary) 178 | 179 | if "Jump" in node_key: 180 | for key in data: 181 | if "Jump" in key and data[node_key].text == data[key].text and node_key != key: 182 | block = generate_block(data[key].connects_to[0]) 183 | break 184 | 185 | if "Condition" in node_key: # this isn't very DRY 186 | block.condition = process_condition(node_key) 187 | block = process_block(block) 188 | 189 | # For each key of the connected nodes we put it on the block 190 | for connected_node_key in data[node_key].connects_to: 191 | if "Dialogue" in connected_node_key: 192 | if not block.dialogue.empty(): # It doesn't make sense to connect two dialogue nodes 193 | print("[WARN]: more than one Dialogue node connected. Defaulting to the first, key: %s, text: %s." % [block.dialogue.key, block.text]) 194 | continue 195 | 196 | var dialogue = { 197 | key = connected_node_key, 198 | } 199 | block.dialogue = dialogue 200 | 201 | elif "Option" in connected_node_key: 202 | var option = { 203 | key = connected_node_key, 204 | text = data[connected_node_key].text, 205 | } 206 | block.options.append(option) 207 | 208 | elif "Expression" in connected_node_key: 209 | var expression = { 210 | key = connected_node_key, 211 | logic = data[connected_node_key].logic 212 | } 213 | block.expressions.append(expression) 214 | 215 | elif "Condition" in connected_node_key: 216 | if not block.condition.empty(): # It also doesn't make sense to connect two Condition nodes 217 | print("[WARN]: more than one Condition node connected. Defaulting to the first, key: %s." % block.condition.key) 218 | continue 219 | 220 | block.condition = process_condition(connected_node_key) 221 | 222 | var parse_condition = handle_condition(block.condition) 223 | 224 | if 'Option' in parse_condition.key: 225 | var option = { 226 | key = parse_condition.key, 227 | text = data[parse_condition.key].text, 228 | } 229 | block.options.append(option) 230 | 231 | elif "Jump" in connected_node_key: 232 | if not block.jump.empty(): 233 | print("[WARN]: more than one Jump node connected. Defaulting to the first, key: %s, id: %d." % [connected_node_key, block.jump.id]) 234 | continue 235 | 236 | # Just like with the Expression node a linear search is needed to find the matching jump node. 237 | var match_key : String 238 | for key in data: 239 | if "Jump" in key and data[connected_node_key].text == data[key].text and connected_node_key != key: 240 | match_key = key 241 | break 242 | 243 | if not match_key: 244 | print("[ERROR]: no other node with the id %s was found. Aborting." % data[connected_node_key].text) 245 | return {} 246 | 247 | var jump = { 248 | key = connected_node_key, 249 | id = data[connected_node_key].text, 250 | goes_to_key = match_key 251 | } 252 | block.jump = jump 253 | 254 | var jump_options = handle_jump(block.jump) 255 | if not jump_options.options.empty(): 256 | for option in jump_options.options: 257 | block.options.append(option) 258 | 259 | elif "End" in connected_node_key and not "Jump" in node_key: 260 | block.is_final = true 261 | 262 | current_block = block 263 | return current_block 264 | 265 | func process_condition(passed_key : String) -> Dictionary: 266 | # Sadly the only way to find the Expression node that serves as input is to make a linear search 267 | var input_logic : String 268 | for key in data: 269 | if "Expression" in key and data[key].connects_to.front() == passed_key: 270 | input_logic = data[key].logic 271 | break 272 | 273 | if not input_logic: 274 | print("[ERROR]: no input for the condition node %s was found." % passed_key) 275 | return {} 276 | 277 | var condition = { 278 | key = passed_key, 279 | logic = input_logic, 280 | goes_to_key = { 281 | if_true = data[passed_key].conditions["true"], 282 | if_false = data[passed_key].conditions["false"] 283 | } 284 | } 285 | 286 | return condition 287 | 288 | func set_format_dictionary(value : Dictionary) -> void: 289 | format_dictionary = value 290 | --------------------------------------------------------------------------------