## A single compilation instance of some dialogue. class_name DMCompilation extends RefCounted #region Compilation locals ## A list of file paths that were imported by this file. var imported_paths: PackedStringArray = [] ## A list of state names from "using" clauses. var using_states: PackedStringArray = [] ## A map of titles in this file. var titles: Dictionary = {} ## The first encountered title in this file. var first_title: String = "" ## A list of character names in this file. var character_names: PackedStringArray = [] ## A list of any compilation errors. var errors: Array[Dictionary] = [] ## A map of all compiled lines. var lines: Dictionary = {} ## A flattened and simplified map of compiled lines for storage in a resource. var data: Dictionary = {} #endregion #region Internal variables # A list of all [RegEx] references var regex: DMCompilerRegEx = DMCompilerRegEx.new() # For parsing condition/mutation expressions var expression_parser: DMExpressionParser = DMExpressionParser.new() # A map of titles that came from imported files. var _imported_titles: Dictionary = {} # Used to keep track of circular imports. var _imported_line_map: Dictionary = {} # The number of imported lines. var _imported_line_count: int = 0 # A list of already encountered static line IDs. var _known_translation_keys: Dictionary = {} # A noop for retrieving the next line without conditions. var _first: Callable = func(_s): return true # Title jumps are adjusted as they are parsed so any goto lines might need to be adjusted after they are first seen. var _goto_lines: Dictionary = {} #endregion #region Main ## Compile some text. func compile(text: String, path: String = ".") -> Error: titles = {} character_names = [] parse_line_tree(build_line_tree(inject_imported_files(text + "\n=> END", path))) # Convert the compiles lines to a Dictionary so they can be stored. for id in lines: var line: DMCompiledLine = lines[id] data[id] = line.to_data() if errors.size() > 0: return ERR_PARSE_ERROR return OK ## Inject any imported files func inject_imported_files(text: String, path: String) -> PackedStringArray: # Work out imports var known_imports: Dictionary = {} # Include the base file path so that we can get around circular dependencies known_imports[path.hash()] = "." var raw_lines: PackedStringArray = text.split("\n") for id in range(0, raw_lines.size()): var line = raw_lines[id] if is_import_line(line): var import_data: Dictionary = extract_import_path_and_name(line) if not import_data.has("path"): continue var import_hash: int = import_data.path.hash() if import_data.size() > 0: # Keep track of titles so we can add imported ones later if str(import_hash) in _imported_titles.keys(): add_error(id, 0, DMConstants.ERR_FILE_ALREADY_IMPORTED) if import_data.prefix in _imported_titles.values(): add_error(id, 0, DMConstants.ERR_DUPLICATE_IMPORT_NAME) _imported_titles[str(import_hash)] = import_data.prefix # Import the file content if not known_imports.has(import_hash): var error: Error = import_content(import_data.path, import_data.prefix, _imported_line_map, known_imports) if error != OK: add_error(id, 0, error) # Make a map so we can refer compiled lines to where they were imported from if not _imported_line_map.has(import_hash): _imported_line_map[import_hash] = { hash = import_hash, imported_on_line_number = id, from_line = 0, to_line = 0 } var imported_content: String = "" var cummulative_line_number: int = 0 for item in _imported_line_map.values(): item["from_line"] = cummulative_line_number if known_imports.has(item.hash): cummulative_line_number += known_imports[item.hash].split("\n").size() item["to_line"] = cummulative_line_number if known_imports.has(item.hash): imported_content += known_imports[item.hash] + "\n" if imported_content == "": _imported_line_count = 0 return text.split("\n") else: _imported_line_count = cummulative_line_number + 1 # Combine imported lines with the original lines return (imported_content + "\n" + text).split("\n") ## Import content from another dialogue file or return an ERR func import_content(path: String, prefix: String, imported_line_map: Dictionary, known_imports: Dictionary) -> Error: if FileAccess.file_exists(path): var file = FileAccess.open(path, FileAccess.READ) var content: PackedStringArray = file.get_as_text().strip_edges().split("\n") for index in range(0, content.size()): var line = content[index] if is_import_line(line): var import = extract_import_path_and_name(line) if import.size() > 0: if not known_imports.has(import.path.hash()): # Add an empty record into the keys just so we don't end up with cyclic dependencies known_imports[import.path.hash()] = "" if import_content(import.path, import.prefix, imported_line_map, known_imports) != OK: return ERR_LINK_FAILED if not imported_line_map.has(import.path.hash()): # Make a map so we can refer compiled lines to where they were imported from imported_line_map[import.path.hash()] = { hash = import.path.hash(), imported_on_line_number = index, from_line = 0, to_line = 0 } _imported_titles[import.prefix] = import.path.hash() var origin_hash: int = -1 for hash_value in known_imports.keys(): if known_imports[hash_value] == ".": origin_hash = hash_value # Replace any titles or jump points with references to the files they point to (event if they point to their own file) for i in range(0, content.size()): var line = content[i] if line.strip_edges().begins_with("~ "): var indent: String = "\t".repeat(get_indent(line)) var title = line.strip_edges().substr(2) if "/" in line: var bits = title.split("/") content[i] = "%s~ %s/%s" % [indent, _imported_titles[bits[0]], bits[1]] else: content[i] = "%s~ %s/%s" % [indent, str(path.hash()), title] elif "=>< " in line: var jump: String = line.substr(line.find("=>< ") + "=>< ".length()).strip_edges() if "/" in jump: var bits: PackedStringArray = jump.split("/") var title_hash: int = _imported_titles[bits[0]] if title_hash == origin_hash: content[i] = "%s=>< %s" % [line.split("=>< ")[0], bits[1]] else: content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], title_hash, bits[1]] elif not jump in ["END", "END!"]: content[i] = "%s=>< %s/%s" % [line.split("=>< ")[0], str(path.hash()), jump] elif "=> " in line: var jump: String = line.substr(line.find("=> ") + "=> ".length()).strip_edges() if "/" in jump: var bits: PackedStringArray = jump.split("/") var title_hash: int = _imported_titles[bits[0]] if title_hash == origin_hash: content[i] = "%s=> %s" % [line.split("=> ")[0], bits[1]] else: content[i] = "%s=> %s/%s" % [line.split("=> ")[0], title_hash, bits[1]] elif not jump in ["END", "END!"]: content[i] = "%s=> %s/%s" % [line.split("=> ")[0], str(path.hash()), jump] imported_paths.append(path) known_imports[path.hash()] = "\n".join(content) + "\n=> END\n" return OK else: return ERR_FILE_NOT_FOUND ## Build a tree of parent/child relationships func build_line_tree(raw_lines: PackedStringArray) -> DMTreeLine: var root: DMTreeLine = DMTreeLine.new("") var parent_chain: Array[DMTreeLine] = [root] var previous_line: DMTreeLine var doc_comments: PackedStringArray = [] # Get list of known autoloads var autoload_names: PackedStringArray = get_autoload_names() for i in range(0, raw_lines.size()): var raw_line: String = raw_lines[i] var tree_line: DMTreeLine = DMTreeLine.new(str(i - _imported_line_count)) tree_line.line_number = i + 1 tree_line.type = get_line_type(raw_line) tree_line.text = raw_line.strip_edges() # Handle any "using" directives. if tree_line.type == DMConstants.TYPE_USING: var using_match: RegExMatch = regex.USING_REGEX.search(raw_line) if "state" in using_match.names: var using_state: String = using_match.strings[using_match.names.state].strip_edges() if not using_state in autoload_names: add_error(tree_line.line_number, 0, DMConstants.ERR_UNKNOWN_USING) elif not using_state in using_states: using_states.append(using_state) continue # Ignore import lines because they've already been processed. elif is_import_line(raw_line): continue tree_line.indent = get_indent(raw_line) # Attach doc comments if raw_line.strip_edges().begins_with("##"): doc_comments.append(raw_line.replace("##", "").strip_edges()) elif tree_line.type == DMConstants.TYPE_DIALOGUE: tree_line.notes = "\n".join(doc_comments) doc_comments.clear() # Empty lines are only kept so that we can work out groupings of things (eg. randomised # lines). Therefore we only need to keep one empty line in a row even if there # are multiple. The indent of an empty line is assumed to be the same as the non-empty line # following it. That way, grouping calculations should work. if tree_line.type in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_COMMENT] and raw_lines.size() > i + 1: var next_line = raw_lines[i + 1] if get_line_type(next_line) in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_COMMENT]: continue else: tree_line.type = DMConstants.TYPE_UNKNOWN tree_line.indent = get_indent(next_line) # Nothing should be more than a single indent past its parent. if tree_line.indent > parent_chain.size(): add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_INDENTATION) # Check for indentation changes if tree_line.indent > parent_chain.size() - 1: parent_chain.append(previous_line) elif tree_line.indent < parent_chain.size() - 1: parent_chain.resize(tree_line.indent + 1) # Add any titles to the list of known titles if tree_line.type == DMConstants.TYPE_TITLE: var title: String = tree_line.text.substr(2) if title == "": add_error(i, 2, DMConstants.ERR_EMPTY_TITLE) elif titles.has(title): add_error(i, 2, DMConstants.ERR_DUPLICATE_TITLE) else: titles[title] = tree_line.id if "/" in title: # Replace the hash title with something human readable. var bits: PackedStringArray = title.split("/") if _imported_titles.has(bits[0]): title = _imported_titles[bits[0]] + "/" + bits[1] titles[title] = tree_line.id elif first_title == "" and i >= _imported_line_count: first_title = tree_line.id # Append the current line to the current parent (note: the root is the most basic parent). var parent: DMTreeLine = parent_chain[parent_chain.size() - 1] tree_line.parent = weakref(parent) parent.children.append(tree_line) previous_line = tree_line return root #endregion #region Parsing func parse_line_tree(root: DMTreeLine, parent: DMCompiledLine = null) -> Array[DMCompiledLine]: var compiled_lines: Array[DMCompiledLine] = [] for i in range(0, root.children.size()): var tree_line: DMTreeLine = root.children[i] var line: DMCompiledLine = DMCompiledLine.new(tree_line.id, tree_line.type) match line.type: DMConstants.TYPE_UNKNOWN: line.next_id = get_next_matching_sibling_id(root.children, i, parent, _first) DMConstants.TYPE_TITLE: parse_title_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_CONDITION: parse_condition_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_WHILE: parse_while_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_MATCH: parse_match_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_WHEN: parse_when_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_MUTATION: parse_mutation_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_GOTO: # Extract any weighted random calls before parsing dialogue if tree_line.text.begins_with("%"): parse_random_line(tree_line, line, root.children, i, parent) parse_goto_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_RESPONSE: parse_response_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_RANDOM: parse_random_line(tree_line, line, root.children, i, parent) DMConstants.TYPE_DIALOGUE: # Extract any weighted random calls before parsing dialogue if tree_line.text.begins_with("%"): parse_random_line(tree_line, line, root.children, i, parent) parse_dialogue_line(tree_line, line, root.children, i, parent) # Main line map is keyed by ID lines[line.id] = line # Returned lines order is preserved so that it can be used for compiling children compiled_lines.append(line) return compiled_lines ## Parse a title and apply it to the given line func parse_title_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK line.text = tree_line.text.substr(tree_line.text.find("~ ") + 2).strip_edges() # Titles can't have numbers as the first letter (unless they are external titles which get replaced with hashes) if tree_line.line_number >= _imported_line_count and regex.BEGINS_WITH_NUMBER_REGEX.search(line.text): result = add_error(tree_line.line_number, 2, DMConstants.ERR_TITLE_BEGINS_WITH_NUMBER) # Only import titles are allowed to have "/" in them var valid_title = regex.VALID_TITLE_REGEX.search(line.text.replace("/", "")) if not valid_title: result = add_error(tree_line.line_number, 2, DMConstants.ERR_TITLE_INVALID_CHARACTERS) line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) ## Update the titles reference to point to the actual first line titles[line.text] = line.next_id ## Update any lines that point to this title if _goto_lines.has(line.text): for goto_line in _goto_lines[line.text]: goto_line.next_id = line.next_id return result ## Parse a goto and apply it to the given line. func parse_goto_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: # Work out where this line is jumping to. var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(tree_line.text, titles) if goto_data.error: return add_error(tree_line.line_number, tree_line.indent + 2, goto_data.error) if goto_data.next_id or goto_data.expression: line.next_id = goto_data.next_id line.next_id_expression = goto_data.expression add_reference_to_title(goto_data.title, line) if goto_data.is_snippet: line.is_snippet = true line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) return OK ## Parse a condition line and apply to the given line func parse_condition_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: # Work out the next IDs before parsing the condition line itself so that the last # child can inherit from the chain. # Find the next conditional sibling that is part of this grouping (if there is one). for next_sibling: DMTreeLine in siblings.slice(sibling_index + 1): if not next_sibling.type in [DMConstants.TYPE_UNKNOWN, DMConstants.TYPE_CONDITION]: break elif next_sibling.type == DMConstants.TYPE_CONDITION: if next_sibling.text.begins_with("el"): line.next_sibling_id = next_sibling.id break else: break line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine): # The next line that isn't a conditional or is a new "if" return s.type != DMConstants.TYPE_CONDITION or s.text.begins_with("if ") ) # Any empty IDs should end the conversation. if line.next_id_after == DMConstants.ID_NULL: line.next_id_after = parent.next_id_after if parent != null and parent.next_id_after else DMConstants.ID_END # Having no nested body is an immediate failure. if tree_line.children.size() == 0: return add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION) # Try to parse the conditional expression ("else" has no expression). if "if " in tree_line.text: var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent) if condition.has("error"): return add_error(tree_line.line_number, condition.index, condition.error) else: line.expression = condition # Parse any nested body lines parse_children(tree_line, line) return OK ## Parse a while loop and apply it to the given line. func parse_while_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) # Parse the while condition var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent) if condition.has("error"): return add_error(tree_line.line_number, condition.index, condition.error) else: line.expression = condition # Parse the nested body (it should take care of looping back to this line when it finishes) parse_children(tree_line, line) return OK func parse_match_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK # The next line after is the next sibling line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) # Extract the condition to match to var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent) if condition.has("error"): result = add_error(tree_line.line_number, condition.index, condition.error) else: line.expression = condition # Match statements should have children if tree_line.children.size() == 0: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION) # Check that all children are when or else. for child in tree_line.children: if child.type == DMConstants.TYPE_WHEN: continue if child.type == DMConstants.TYPE_UNKNOWN: continue if child.type == DMConstants.TYPE_CONDITION and child.text.begins_with("else"): continue result = add_error(child.line_number, child.indent, DMConstants.ERR_EXPECTED_WHEN_OR_ELSE) # Each child should be a "when" or "else". We don't need those lines themselves, just their # condition and the line they point to if the conditions passes. var children: Array[DMCompiledLine] = parse_children(tree_line, line) for child: DMCompiledLine in children: # "when" cases if child.type == DMConstants.TYPE_WHEN: line.siblings.append({ condition = child.expression, next_id = child.next_id }) # "else" case elif child.type == DMConstants.TYPE_CONDITION: if line.siblings.any(func(s): return s.has("is_else")): result = add_error(child.line_number, child.indent, DMConstants.ERR_ONLY_ONE_ELSE_ALLOWED) else: line.siblings.append({ next_id = child.next_id, is_else = true }) # Remove the line from the list of all lines because we don't need it any more. lines.erase(child.id) return result func parse_when_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK # This when line should be found inside a match line if parent.type != DMConstants.TYPE_MATCH: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_WHEN_MUST_BELONG_TO_MATCH) # When lines should have children if tree_line.children.size() == 0: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_CONDITION_INDENTATION) # The next line after a when is the same as its parent match line line.next_id_after = parent.next_id_after # Extract the condition to match to var condition: Dictionary = extract_condition(tree_line.text, false, tree_line.indent) if condition.has("error"): result = add_error(tree_line.line_number, condition.index, condition.error) else: line.expression = condition parse_children(tree_line, line) return result ## Parse a mutation line and apply it to the given line func parse_mutation_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var mutation: Dictionary = extract_mutation(tree_line.text) if mutation.has("error"): return add_error(tree_line.line_number, mutation.index, mutation.error) else: line.expression = mutation line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) return OK ## Parse a response and apply it to the given line. func parse_response_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK # Remove the "- " tree_line.text = tree_line.text.substr(2) # Extract the static line ID var static_line_id: String = extract_static_line_id(tree_line.text) if static_line_id: tree_line.text = tree_line.text.replace("[ID:%s]" % [static_line_id], "") line.translation_key = static_line_id # Handle conditional responses and remove them from the prompt text. if " [if " in tree_line.text: var condition = extract_condition(tree_line.text, true, tree_line.indent) if condition.has("error"): result = add_error(tree_line.line_number, condition.index, condition.error) else: line.expression = condition # Extract just the raw condition text var found: RegExMatch = regex.WRAPPED_CONDITION_REGEX.search(tree_line.text) line.expression_text = found.strings[found.names.expression] tree_line.text = regex.WRAPPED_CONDITION_REGEX.sub(tree_line.text, "").strip_edges() # Find the original response in this group of responses. var original_response: DMTreeLine = tree_line for i in range(sibling_index - 1, -1, -1): if siblings[i].type == DMConstants.TYPE_RESPONSE: original_response = siblings[i] elif siblings[i].type != DMConstants.TYPE_UNKNOWN: break # If it's the original response then set up an original line. if original_response == tree_line: line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, (func(s: DMTreeLine): # The next line that isn't a response. return not s.type in [DMConstants.TYPE_RESPONSE, DMConstants.TYPE_UNKNOWN] ), true) line.responses = [line.id] # If this line has children then the next ID is the first child. if tree_line.children.size() > 0: parse_children(tree_line, line) # Otherwise use the same ID for after the random group. else: line.next_id = line.next_id_after # Otherwise let the original line know about it. else: var original_line: DMCompiledLine = lines[original_response.id] line.next_id_after = original_line.next_id_after line.siblings = original_line.siblings original_line.responses.append(line.id) # If this line has children then the next ID is the first child. if tree_line.children.size() > 0: parse_children(tree_line, line) # Otherwise use the original line's next ID after. else: line.next_id = original_line.next_id_after parse_character_and_dialogue(tree_line, line, siblings, sibling_index, parent) return OK ## Parse a randomised line func parse_random_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: # Find the weight var weight: float = 1 var found = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.search(tree_line.text + " ") var condition: Dictionary = {} if found: if found.names.has("weight"): weight = found.strings[found.names.weight].to_float() if found.names.has("condition"): condition = extract_condition(tree_line.text, true, tree_line.indent) # Find the original random sibling. It will be the jump off point. var original_sibling: DMTreeLine = tree_line for i in range(sibling_index - 1, -1, -1): if siblings[i] and siblings[i].is_random: original_sibling = siblings[i] else: break var weighted_sibling: Dictionary = { weight = weight, id = line.id, condition = condition } # If it's the original sibling then set up an original line. if original_sibling == tree_line: line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, (func(s: DMTreeLine): # The next line that isn't a randomised line. # NOTE: DMTreeLine.is_random won't be set at this point so we need to check for the "%" prefix. return not s.text.begins_with("%") ), true) line.siblings = [weighted_sibling] # If this line has children then the next ID is the first child. if tree_line.children.size() > 0: parse_children(tree_line, line) # Otherwise use the same ID for after the random group. else: line.next_id = line.next_id_after # Otherwise let the original line know about it. else: var original_line: DMCompiledLine = lines[original_sibling.id] line.next_id_after = original_line.next_id_after line.siblings = original_line.siblings original_line.siblings.append(weighted_sibling) # If this line has children then the next ID is the first child. if tree_line.children.size() > 0: parse_children(tree_line, line) # Otherwise use the original line's next ID after. else: line.next_id = original_line.next_id_after # Remove the randomise syntax from the line. tree_line.text = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(tree_line.text, "") tree_line.is_random = true return OK ## Parse some dialogue and apply it to the given line. func parse_dialogue_line(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK # Remove escape character if tree_line.text.begins_with("\\using"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\if"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\elif"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\else"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\while"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\match"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\when"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\do"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\set"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\-"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\~"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\=>"): tree_line.text = tree_line.text.substr(1) if tree_line.text.begins_with("\\%"): tree_line.text = tree_line.text.substr(1) # Append any further dialogue for i in range(0, tree_line.children.size()): var child: DMTreeLine = tree_line.children[i] if child.type == DMConstants.TYPE_DIALOGUE: # Nested dialogue lines cannot have further nested dialogue. if child.children.size() > 0: add_error(child.children[0].line_number, child.children[0].indent, DMConstants.ERR_INVALID_INDENTATION) # Mark this as a dialogue child of another dialogue line. child.is_nested_dialogue = true var child_line = DMCompiledLine.new("", DMConstants.TYPE_DIALOGUE) parse_character_and_dialogue(child, child_line, [], 0, parent) var child_static_line_id: String = extract_static_line_id(child.text) if child_line.character != "" or child_static_line_id != "": add_error(child.line_number, child.indent, DMConstants.ERR_UNEXPECTED_SYNTAX_ON_NESTED_DIALOGUE_LINE) # Check that only the last child (or none) has a jump reference if i < tree_line.children.size() - 1 and " =>" in child.text: add_error(child.line_number, child.indent, DMConstants.ERR_NESTED_DIALOGUE_INVALID_JUMP) if i == 0 and " =>" in tree_line.text: add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_NESTED_DIALOGUE_INVALID_JUMP) tree_line.text += "\n" + child.text elif child.type == DMConstants.TYPE_UNKNOWN: tree_line.text += "\n" else: result = add_error(child.line_number, child.indent, DMConstants.ERR_INVALID_INDENTATION) # Extract the static line ID var static_line_id: String = extract_static_line_id(tree_line.text) if static_line_id: tree_line.text = tree_line.text.replace(" [ID:", "[ID:").replace("[ID:%s]" % [static_line_id], "") line.translation_key = static_line_id # Check for simultaneous lines if tree_line.text.begins_with("| "): # Jumps are only allowed on the origin line. if " =>" in tree_line.text: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_GOTO_NOT_ALLOWED_ON_CONCURRECT_LINES) # Check for a valid previous line. tree_line.text = tree_line.text.substr(2) var previous_sibling: DMTreeLine = siblings[sibling_index - 1] if previous_sibling.type != DMConstants.TYPE_DIALOGUE: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_CONCURRENT_LINE_WITHOUT_ORIGIN) else: # Because the previous line's concurrent_lines array is the same as # any line before that this doesn't need to check any higher up. var previous_line: DMCompiledLine = lines[previous_sibling.id] previous_line.concurrent_lines.append(line.id) line.concurrent_lines = previous_line.concurrent_lines parse_character_and_dialogue(tree_line, line, siblings, sibling_index, parent) # Check for any inline expression errors var resolved_line_data: DMResolvedLineData = DMResolvedLineData.new("") var bbcodes: Array[Dictionary] = resolved_line_data.find_bbcode_positions_in_string(tree_line.text, true, true) for bbcode: Dictionary in bbcodes: var tag: String = bbcode.code var code: String = bbcode.raw_args if tag.begins_with("do") or tag.begins_with("set") or tag.begins_with("if"): var expression: Array = expression_parser.tokenise(code, DMConstants.TYPE_MUTATION, bbcode.start + bbcode.code.length()) if expression.size() == 0: add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_INVALID_EXPRESSION) elif expression[0].type == DMConstants.TYPE_ERROR: add_error(tree_line.line_number, tree_line.indent + expression[0].i, expression[0].value) # If the line isn't part of a weighted random group then make it point to the next # available sibling. if line.next_id == DMConstants.ID_NULL and line.siblings.size() == 0: line.next_id = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine): # Ignore concurrent lines. return not s.text.begins_with("| ") ) return result ## Parse the character name and dialogue and apply it to a given line. func parse_character_and_dialogue(tree_line: DMTreeLine, line: DMCompiledLine, siblings: Array[DMTreeLine], sibling_index: int, parent: DMCompiledLine) -> Error: var result: Error = OK var text: String = tree_line.text # Attach any doc comments. line.notes = tree_line.notes # Extract tags. var tag_data: DMResolvedTagData = DMResolvedTagData.new(text) line.tags = tag_data.tags text = tag_data.text_without_tags # Handle inline gotos and remove them from the prompt text. if " =><" in text: # Because of when the return point needs to be known at runtime we need to split # this line into two (otherwise the return point would be dependent on the balloon). var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(text, titles) if goto_data.error: result = add_error(tree_line.line_number, tree_line.indent + 3, goto_data.error) if goto_data.next_id or goto_data.expression: text = goto_data.text_without_goto var goto_line: DMCompiledLine = DMCompiledLine.new(line.id + ".1", DMConstants.TYPE_GOTO) goto_line.next_id = goto_data.next_id line.next_id_expression = goto_data.expression if line.type == DMConstants.TYPE_RESPONSE: goto_line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, func(s: DMTreeLine): # If this is coming from a response then we want the next non-response line. return s.type != DMConstants.TYPE_RESPONSE ) else: goto_line.next_id_after = get_next_matching_sibling_id(siblings, sibling_index, parent, _first) goto_line.is_snippet = true lines[goto_line.id] = goto_line line.next_id = goto_line.id add_reference_to_title(goto_data.title, goto_line) elif " =>" in text: var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(text, titles) if goto_data.error: result = add_error(tree_line.line_number, tree_line.indent + 2, goto_data.error) if goto_data.next_id or goto_data.expression: text = goto_data.text_without_goto line.next_id = goto_data.next_id line.next_id_expression = goto_data.expression add_reference_to_title(goto_data.title, line) # Handle the dialogue. text = text.replace("\\:", "!ESCAPED_COLON!") if ": " in text: # If a character was given then split it out. var bits = Array(text.strip_edges().split(": ")) line.character = bits.pop_front().strip_edges() if not line.character in character_names: character_names.append(line["character"]) # Character names can have expressions in them. line.character_replacements = expression_parser.extract_replacements(line.character, tree_line.indent) for replacement in line.character_replacements: if replacement.has("error"): result = add_error(tree_line.line_number, replacement.index, replacement.error) text = ": ".join(bits).replace("!ESCAPED_COLON!", ":") else: line.character = "" text = text.replace("!ESCAPED_COLON!", ":") # Extract any expressions in the dialogue. line.text_replacements = expression_parser.extract_replacements(text, line.character.length() + 2 + tree_line.indent) for replacement in line.text_replacements: if replacement.has("error"): result = add_error(tree_line.line_number, replacement.index, replacement.error) # Replace any newlines. text = text.replace("\\n", "\n").strip_edges() # If there was no manual translation key then just use the text itself (unless this is a # child dialogue below another dialogue line). if not tree_line.is_nested_dialogue and line.translation_key == "": # Show an error if missing translations is enabled if DMSettings.get_setting(DMSettings.MISSING_TRANSLATIONS_ARE_ERRORS, false): result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_MISSING_ID) else: line.translation_key = text line.text = text # IDs can't be duplicated for text that doesn't match. if line.translation_key != "": if _known_translation_keys.has(line.translation_key) and _known_translation_keys.get(line.translation_key) != line.text: result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_DUPLICATE_ID) else: _known_translation_keys[line.translation_key] = line.text return result #endregion #region Errors ## Add a compilation error to the list. Returns the given error code. func add_error(line_number: int, column_number: int, error: int) -> Error: # See if the error was in an imported file for item in _imported_line_map.values(): if line_number < item.to_line: errors.append({ line_number = item.imported_on_line_number, column_number = 0, error = DMConstants.ERR_ERRORS_IN_IMPORTED_FILE, external_error = error, external_line_number = line_number }) return error # Otherwise, it's in this file errors.append({ line_number = line_number - _imported_line_count, column_number = column_number, error = error }) return error #endregion #region Helpers ## Get the names of any autoloads in the project. func get_autoload_names() -> PackedStringArray: var autoloads: PackedStringArray = [] var project = ConfigFile.new() project.load("res://project.godot") if project.has_section("autoload"): return Array(project.get_section_keys("autoload")).filter(func(key): return key != "DialogueManager") return autoloads ## Check if a line is importing another file. func is_import_line(text: String) -> bool: return text.begins_with("import ") and " as " in text ## Extract the import information from an import line func extract_import_path_and_name(line: String) -> Dictionary: var found: RegExMatch = regex.IMPORT_REGEX.search(line) if found: return { path = found.strings[found.names.path], prefix = found.strings[found.names.prefix] } else: return {} ## Get the indent of a raw line func get_indent(raw_line: String) -> int: var tabs: RegExMatch = regex.INDENT_REGEX.search(raw_line) if tabs: return tabs.get_string().length() else: return 0 ## Get the type of a raw line func get_line_type(raw_line: String) -> String: raw_line = raw_line.strip_edges() var text: String = regex.WEIGHTED_RANDOM_SIBLINGS_REGEX.sub(raw_line + " ", "").strip_edges() if text.begins_with("import "): return DMConstants.TYPE_IMPORT if text.begins_with("using "): return DMConstants.TYPE_USING if text.begins_with("#"): return DMConstants.TYPE_COMMENT if text.begins_with("~ "): return DMConstants.TYPE_TITLE if text.begins_with("if ") or text.begins_with("elif") or text.begins_with("else"): return DMConstants.TYPE_CONDITION if text.begins_with("while "): return DMConstants.TYPE_WHILE if text.begins_with("match "): return DMConstants.TYPE_MATCH if text.begins_with("when "): return DMConstants.TYPE_WHEN if text.begins_with("do ") or text.begins_with("do! ") or text.begins_with("set "): return DMConstants.TYPE_MUTATION if text.begins_with("=> ") or text.begins_with("=>< "): return DMConstants.TYPE_GOTO if text.begins_with("- "): return DMConstants.TYPE_RESPONSE if raw_line.begins_with("%") and text.is_empty(): return DMConstants.TYPE_RANDOM if not text.is_empty(): return DMConstants.TYPE_DIALOGUE return DMConstants.TYPE_UNKNOWN ## Get the next sibling that passes a [Callable] matcher. func get_next_matching_sibling_id(siblings: Array[DMTreeLine], from_index: int, parent: DMCompiledLine, matcher: Callable, with_empty_lines: bool = false) -> String: for i in range(from_index + 1, siblings.size()): var next_sibling: DMTreeLine = siblings[i] if not with_empty_lines: # Ignore empty lines if not next_sibling or next_sibling.type == DMConstants.TYPE_UNKNOWN: continue if matcher.call(next_sibling): return next_sibling.id # If no next ID can be found then check the parent for where to go next. if parent != null: return parent.id if parent.type == DMConstants.TYPE_WHILE else parent.next_id_after return DMConstants.ID_NULL ## Extract a static line ID from some text. func extract_static_line_id(text: String) -> String: # Find a static translation key, eg. [ID:something] var found: RegExMatch = regex.STATIC_LINE_ID_REGEX.search(text) if found: return found.strings[found.names.id] else: return "" ## Extract a condition (or inline condition) from some text. func extract_condition(text: String, is_wrapped: bool, index: int) -> Dictionary: var regex: RegEx = regex.WRAPPED_CONDITION_REGEX if is_wrapped else regex.CONDITION_REGEX var found: RegExMatch = regex.search(text) if found == null: return { index = 0, error = DMConstants.ERR_INCOMPLETE_EXPRESSION } var raw_condition: String = found.strings[found.names.expression] if raw_condition.ends_with(":"): raw_condition = raw_condition.substr(0, raw_condition.length() - 1) var expression: Array = expression_parser.tokenise(raw_condition, DMConstants.TYPE_CONDITION, index + found.get_start("expression")) if expression.size() == 0: return { index = index + found.get_start("expression"), error = DMConstants.ERR_INCOMPLETE_EXPRESSION } elif expression[0].type == DMConstants.TYPE_ERROR: return { index = expression[0].i, error = expression[0].value } else: return { expression = expression } ## Extract a mutation from some text. func extract_mutation(text: String) -> Dictionary: var found: RegExMatch = regex.MUTATION_REGEX.search(text) if not found: return { index = 0, error = DMConstants.ERR_INCOMPLETE_EXPRESSION } if found.names.has("expression"): var expression: Array = expression_parser.tokenise(found.strings[found.names.expression], DMConstants.TYPE_MUTATION, found.get_start("expression")) if expression.size() == 0: return { index = found.get_start("expression"), error = DMConstants.ERR_INCOMPLETE_EXPRESSION } elif expression[0].type == DMConstants.TYPE_ERROR: return { index = expression[0].i, error = expression[0].value } else: return { expression = expression, is_blocking = not "!" in found.strings[found.names.keyword] } else: return { index = found.get_start(), error = DMConstants.ERR_INCOMPLETE_EXPRESSION } ## Keep track of lines referencing titles because their own next_id might not have been resolved yet. func add_reference_to_title(title: String, line: DMCompiledLine) -> void: if title in [DMConstants.ID_END, DMConstants.ID_END_CONVERSATION, DMConstants.ID_NULL]: return if not _goto_lines.has(title): _goto_lines[title] = [] _goto_lines[title].append(line) ## Parse a nested block of child lines func parse_children(tree_line: DMTreeLine, line: DMCompiledLine) -> Array[DMCompiledLine]: var children = parse_line_tree(tree_line, line) if children.size() > 0: line.next_id = children.front().id # The last child should jump to the next line after its parent condition group var last_child: DMCompiledLine = children.back() if last_child.next_id == DMConstants.ID_NULL: last_child.next_id = line.next_id_after if last_child.siblings.size() > 0: for sibling in last_child.siblings: lines.get(sibling.id).next_id = last_child.next_id return children #endregion