1058 lines
39 KiB
GDScript
1058 lines
39 KiB
GDScript
## A single compilation instance of some dialogue.
|
|
class_name DMCompilation extends RefCounted
|
|
|
|
|
|
#region Compilation locals
|
|
|
|
|
|
## The current file path
|
|
var file_path: String
|
|
|
|
## 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 External processing
|
|
|
|
|
|
var processor: DMDialogueProcessor = null
|
|
|
|
|
|
#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 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:
|
|
file_path = path
|
|
titles = {}
|
|
character_names = []
|
|
|
|
text += "\n=> END"
|
|
|
|
# Remove any known static IDs for this file
|
|
for key: String in DMCache.known_static_ids.keys():
|
|
if DMCache.known_static_ids.get(key) == file_path:
|
|
DMCache.known_static_ids.erase(key)
|
|
|
|
find_imported_titles(text, file_path)
|
|
parse_line_tree(build_line_tree(text.split("\n")))
|
|
|
|
# Convert the compiles lines to a Dictionary so they can be stored.
|
|
for id: String 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 find_imported_titles(text: String, path: String) -> void:
|
|
# Work out imports
|
|
var known_imports: Dictionary = {}
|
|
|
|
# Include the base file path so that we can get around circular dependencies
|
|
known_imports[path] = "."
|
|
|
|
var raw_lines: PackedStringArray = text.split("\n")
|
|
|
|
for id: int in range(0, raw_lines.size()):
|
|
var line: String = raw_lines[id]
|
|
|
|
if not is_import_line(line): continue
|
|
|
|
var import_data: Dictionary = extract_import_path_and_name(line)
|
|
|
|
if import_data.size() == 0 or not import_data.has("path"): continue
|
|
|
|
if known_imports.has(import_data.path):
|
|
add_error(id, 0, DMConstants.ERR_FILE_ALREADY_IMPORTED)
|
|
elif known_imports.values().has(import_data.prefix):
|
|
add_error(id, 0, DMConstants.ERR_DUPLICATE_IMPORT_NAME)
|
|
else:
|
|
# Get titles from other file and map them to the known list of titles.
|
|
var imported_resource: DialogueResource = ResourceLoader.load(import_data.path, "", ResourceLoader.CACHE_MODE_REPLACE)
|
|
|
|
# Guard against failed loads -- namely during reimport cascade.
|
|
if imported_resource == null:
|
|
# Might be worth investigating a better constant here.
|
|
add_error(id, 0, DMConstants.ERR_ERRORS_IN_IMPORTED_FILE)
|
|
continue
|
|
|
|
var uid: String = ResourceUID.id_to_text(ResourceLoader.get_resource_uid(import_data.path)).replace("uid://", "")
|
|
for title_key: String in imported_resource.titles:
|
|
# Ignore any titles that are already a reference
|
|
if "/" in title_key: continue
|
|
# Create "alias/title" to "uid@id" mappig
|
|
var title_reference: String = "%s/%s" % [import_data.prefix, title_key]
|
|
titles[title_reference] = "%s@%s" % [uid, imported_resource.titles.get(title_key)]
|
|
|
|
imported_paths.append(import_data.path)
|
|
known_imports[import_data.path] = import_data.prefix
|
|
|
|
|
|
## 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: int in range(0, raw_lines.size()):
|
|
var raw_line: String = get_processor()._preprocess_line(raw_lines[i])
|
|
var tree_line: DMTreeLine = DMTreeLine.new(str(i))
|
|
var line_without_indent: String = strip_indent(raw_line)
|
|
|
|
tree_line.line_number = i + 1
|
|
tree_line.type = get_line_type(raw_line)
|
|
tree_line.text = line_without_indent.strip_edges()
|
|
|
|
# Handle any "using" directives.
|
|
if tree_line.type == DMConstants.TYPE_USING:
|
|
var using_match: RegExMatch = regex.USING_REGEX.search(line_without_indent)
|
|
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(line_without_indent):
|
|
continue
|
|
|
|
tree_line.indent = get_indent(raw_line)
|
|
|
|
# Attach doc comments
|
|
if tree_line.text.begins_with("##"):
|
|
doc_comments.append(tree_line.text.replace("##", "").strip_edges())
|
|
elif tree_line.type == DMConstants.TYPE_DIALOGUE or tree_line.type == DMConstants.TYPE_RESPONSE:
|
|
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: String = 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 + 1, 2, DMConstants.ERR_DUPLICATE_TITLE)
|
|
else:
|
|
titles[title] = tree_line.id
|
|
if first_title == "":
|
|
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: int 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
|
|
|
|
# Apply any post-processing.
|
|
get_processor()._process_line(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
|
|
if 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: RegExMatch = 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: DMCompiledLine 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: DMTreeLine 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)
|
|
|
|
# Attach any doc comments.
|
|
line.notes = tree_line.notes
|
|
|
|
# Extract the static line ID
|
|
var static_line_id: String = extract_static_line_id(tree_line.text)
|
|
if static_line_id:
|
|
if DMCache.known_static_ids.has(static_line_id):
|
|
result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_DUPLICATE_ID)
|
|
else:
|
|
DMCache.known_static_ids[static_line_id] = file_path
|
|
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: Dictionary = 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: int 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: RegExMatch = 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: int 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: int 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 = 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:
|
|
if tree_line.text == "[ID:%s]" % [static_line_id]:
|
|
result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_LONELY_STATIC_ID)
|
|
|
|
if DMCache.known_static_ids.has(static_line_id):
|
|
result = add_error(tree_line.line_number, tree_line.indent, DMConstants.ERR_DUPLICATE_ID)
|
|
else:
|
|
DMCache.known_static_ids[static_line_id] = file_path
|
|
|
|
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("$>") or 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 = Array(text.strip_edges().split(": "))
|
|
line.character = bits.pop_front().strip_edges().replace("!ESCAPED_COLON!", ":")
|
|
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: Dictionary 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: Dictionary 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
|
|
|
|
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:
|
|
errors.append({
|
|
line_number = line_number,
|
|
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 = 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 {}
|
|
|
|
|
|
## Load the configured processor (or the default one is none configured).
|
|
func get_processor() -> DMDialogueProcessor:
|
|
if processor == null:
|
|
var processor_path: String = DMSettings.get_setting(DMSettings.DIALOGUE_PROCESSOR_PATH, "")
|
|
processor = DMDialogueProcessor.new() if processor_path.is_empty() else load(processor_path).new()
|
|
return processor
|
|
|
|
|
|
## Get the indent of a raw line
|
|
func get_indent(raw_line: String) -> int:
|
|
var indent_token: String = get_line_indent_token(raw_line)
|
|
|
|
if indent_token.is_empty(): return 0
|
|
|
|
var indent: int = 0
|
|
var remaining: String = raw_line
|
|
while remaining.begins_with(indent_token):
|
|
indent += 1
|
|
remaining = remaining.substr(indent_token.length())
|
|
|
|
return indent
|
|
|
|
|
|
## Remove any leading indentation token from a raw line.
|
|
func strip_indent(raw_line: String) -> String:
|
|
var indent_token: String = get_line_indent_token(raw_line)
|
|
|
|
if indent_token.is_empty(): return raw_line
|
|
|
|
while raw_line.begins_with(indent_token):
|
|
raw_line = raw_line.substr(indent_token.length())
|
|
|
|
return raw_line
|
|
|
|
|
|
## Get the indentation token used by a line.
|
|
func get_line_indent_token(raw_line: String) -> String:
|
|
for prefix: String in ["\t", "\\t", " ", " "]:
|
|
if raw_line.begins_with(prefix):
|
|
return prefix
|
|
return ""
|
|
|
|
|
|
## Get the type of a raw line
|
|
func get_line_type(raw_line: String) -> String:
|
|
raw_line = strip_indent(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 ") or text.begins_with("$> ") or text.begins_with("$>> "):
|
|
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: int 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] and 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: Dictionary in last_child.siblings:
|
|
lines.get(sibling.id).next_id = last_child.next_id
|
|
|
|
return children
|
|
|
|
|
|
#endregion
|