This repository has been archived on 2024-04-19. You can view files and clone it, but cannot push or open issues or pull requests.
Jessist/addons/dialogue_manager/dialogue_manager.gd

707 lines
23 KiB
GDScript3
Raw Normal View History

2022-06-18 13:05:48 +02:00
extends Node
signal dialogue_started
signal dialogue_finished
const DialogueResource = preload("res://addons/dialogue_manager/dialogue_resource.gd")
const DialogueConstants = preload("res://addons/dialogue_manager/constants.gd")
const DialogueLine = preload("res://addons/dialogue_manager/dialogue_line.gd")
const DialogueResponse = preload("res://addons/dialogue_manager/dialogue_response.gd")
const DialogueSettings = preload("res://addons/dialogue_manager/components/settings.gd")
const DialogueParser = preload("res://addons/dialogue_manager/components/parser.gd")
const ExampleBalloon = preload("res://addons/dialogue_manager/example_balloon/example_balloon.gd")
var resource: DialogueResource
var game_states: Array = []
var auto_translate: bool = true
var settings: DialogueSettings = DialogueSettings.new()
var is_dialogue_running := false setget set_is_dialogue_running
var _node_properties: Array = []
var _resource_cache: Array = []
var _trash: Node = Node.new()
func _ready() -> void:
# Cache the known Node2D properties
_node_properties = ["Script Variables"]
var temp_node = Node2D.new()
for property in temp_node.get_property_list():
_node_properties.append(property.name)
temp_node.free()
# Load the config file (if there is one) so we can set up any global state objects
add_child(settings)
for node_name in settings.get_runtime_value("states", []):
var state = get_node("/root/" + node_name)
if state:
game_states.append(state)
# Add a node for cleaning up
add_child(_trash)
# Step through lines and run any mutations until we either
# hit some dialogue or the end of the conversation
func get_next_dialogue_line(key: String, override_resource: DialogueResource = null) -> DialogueLine:
cleanup()
# Fix up any keys that have spaces in them
key = key.replace(" ", "_").strip_edges()
# You have to provide a dialogue resource
assert(resource != null or override_resource != null, "No dialogue resource provided")
var local_resource: DialogueResource = (override_resource if override_resource != null else resource)
assert(local_resource.syntax_version == DialogueConstants.SYNTAX_VERSION, "This dialogue resource is older than the runtime expects.")
var resource_path = local_resource.resource_path
if local_resource.lines.size() == 0:
# We probably have pre-baking turned off so we need to compile on the fly
local_resource = compile_resource(local_resource)
if local_resource.errors.size() > 0:
# Store in a local var for debugger convenience
var errors = local_resource.errors
printerr("There are %d error(s) in %s" % [errors.size(), resource_path])
for error in errors:
printerr("\tLine %s: %s" % [error.get("line"), error.get("message")])
assert(false, "The provided DialogueResource contains errors. See Output for details.")
var dialogue = get_line(key, local_resource)
yield(get_tree(), "idle_frame")
self.is_dialogue_running = true
# If our dialogue is nothing then we hit the end
if dialogue == null or not is_valid(dialogue):
self.is_dialogue_running = false
return null
# Run the mutation if it is one
if dialogue.type == DialogueConstants.TYPE_MUTATION:
yield(mutate(dialogue.mutation), "completed")
dialogue.queue_free()
if dialogue.next_id in [DialogueConstants.ID_END_CONVERSATION, DialogueConstants.ID_NULL, null]:
# End the conversation
self.is_dialogue_running = false
return null
else:
return get_next_dialogue_line(dialogue.next_id, local_resource)
else:
return dialogue
func replace_values(line_or_response) -> String:
if line_or_response is DialogueLine:
var line: DialogueLine = line_or_response
return get_with_replacements(line.dialogue, line.replacements)
elif line_or_response is DialogueResponse:
var response: DialogueResponse = line_or_response
return get_with_replacements(response.prompt, response.replacements)
else:
return ""
func get_resource_from_text(text: String) -> DialogueResource:
var parser = DialogueParser.new()
var new_resource = DialogueResource.new()
var results = parser.parse(text)
parser.queue_free()
new_resource.raw_text = text
new_resource.syntax_version = DialogueConstants.SYNTAX_VERSION
new_resource.titles = results.get("titles")
new_resource.lines = results.get("lines")
new_resource.errors = results.get("errors")
return new_resource
func show_example_dialogue_balloon(title: String, local_resource: DialogueResource = null) -> void:
var dialogue = yield(get_next_dialogue_line(title, local_resource), "completed")
if dialogue != null:
var balloon = preload("res://addons/dialogue_manager/example_balloon/example_balloon.tscn").instance()
balloon.dialogue = dialogue
get_tree().current_scene.add_child(balloon)
show_example_dialogue_balloon(yield(balloon, "actioned"), local_resource)
### Helpers
func compile_resource(resource: DialogueResource) -> DialogueResource:
# See if we have this cached, first
for item in _resource_cache:
if item[0] == resource.resource_path:
return item[1]
# Otherwise, compile it and then cache it
var next_resource = get_resource_from_text(resource.raw_text)
_resource_cache.insert(0, [resource.resource_path, next_resource])
# Only keep recent stuff in the cache
if _resource_cache.size() > 5:
_resource_cache.remove(5)
return next_resource
# Get a line by its ID
func get_line(key: String, local_resource: DialogueResource) -> DialogueLine:
# End of conversation
if key in [DialogueConstants.ID_NULL, DialogueConstants.ID_END_CONVERSATION, null]:
return null
# See if it is a title
if key.begins_with("~ "):
key = key.substr(2)
if local_resource.titles.has(key):
key = local_resource.titles.get(key)
# Key not found
if not local_resource.lines.has(key):
printerr("Line for key \"%s\" could not be found in %s" % [key, local_resource.resource_path])
assert(false, "The provided DialogueResource does not contain that line key. See Output for details.")
var data = local_resource.lines.get(key)
# Check condtiions
if data.get("type") == DialogueConstants.TYPE_CONDITION:
# "else" will have no actual condition
if data.get("condition") == null or check(data.get("condition")):
return get_line(data.get("next_id"), local_resource)
else:
return get_line(data.get("next_conditional_id"), local_resource)
# Evaluate early exits
if data.get("type") == DialogueConstants.TYPE_GOTO:
return get_line(data.get("next_id"), local_resource)
# Set up a line object
var line = DialogueLine.new(data, auto_translate)
line.dialogue_manager = self
# If we are the first of a list of responses then get the other ones
if data.get("type") == DialogueConstants.TYPE_RESPONSE:
line.responses = get_responses(data.get("responses"), local_resource)
return line
# Add as a child so that it gets cleaned up automatically
_trash.add_child(line)
# Replace any variables in the dialogue text
if data.get("type") == DialogueConstants.TYPE_DIALOGUE and data.has("replacements"):
line.character = get_with_replacements(line.character, line.character_replacements)
line.dialogue = get_with_replacements(line.dialogue, line.replacements)
# Inject the next node's responses if they have any
var next_line = local_resource.lines.get(line.next_id)
if next_line != null and next_line.get("type") == DialogueConstants.TYPE_RESPONSE:
line.responses = get_responses(next_line.get("responses"), local_resource)
return line
func set_is_dialogue_running(value: bool) -> void:
if is_dialogue_running != value:
if value:
emit_signal("dialogue_started")
else:
emit_signal("dialogue_finished")
is_dialogue_running = value
func get_game_states() -> Array:
var current_scene = get_tree().current_scene
var unique_states = []
for state in [current_scene] + game_states:
if not unique_states.has(state):
unique_states.append(state)
return unique_states
# Check if a condition is met
func check(condition: Dictionary) -> bool:
if condition.size() == 0: return true
return resolve(condition.get("expression").duplicate(true))
# Make a change to game state or run a method
func mutate(mutation: Dictionary) -> void:
if mutation == null: return
if mutation.has("function"):
# If lhs is a function then we run it and return because you can't assign to a function
var function_name = mutation.get("function")
var args = resolve_each(mutation.get("args"))
match function_name:
"wait":
yield(get_tree().create_timer(float(args[0])), "timeout")
return
"emit":
for state in get_game_states():
if state.has_signal(args[0]):
match args.size():
1:
state.emit_signal(args[0])
2:
state.emit_signal(args[0], args[1])
3:
state.emit_signal(args[0], args[1], args[2])
4:
state.emit_signal(args[0], args[1], args[2], args[3])
5:
state.emit_signal(args[0], args[1], args[2], args[3], args[4])
6:
state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5])
7:
state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
8:
state.emit_signal(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7])
"debug":
var printable = {}
for i in range(args.size()):
printable[mutation.get("args")[i][0].get("value")] = args[i]
print(printable)
_:
for state in get_game_states():
if state.has_method(function_name):
var result = state.callv(function_name, args)
if result is GDScriptFunctionState and result.is_valid():
yield(result, "completed")
else:
yield(get_tree(), "idle_frame")
return
printerr("'" + function_name + "' is not a method in any game states (" + str(get_game_states()) + ").")
assert(false, "Missing function on current scene or game state. See Output for details.")
elif mutation.has("expression"):
resolve(mutation.get("expression").duplicate(true))
# Wait one frame to give the dialogue handler a chance to yield
yield(get_tree(), "idle_frame")
func resolve_each(array: Array) -> Array:
var results = []
for item in array:
results.append(resolve(item.duplicate(true)))
return results
# Replace any variables, etc in the dialogue with their state values
func get_with_replacements(text: String, replacements: Array) -> String:
for replacement in replacements:
var value = resolve(replacement.get("expression").duplicate(true))
text = text.replace(replacement.get("value_in_text"), str(value))
return text
# Replace an array of line IDs with their response prompts
func get_responses(ids: Array, local_resource: DialogueResource) -> Array:
var responses: Array = []
for id in ids:
var data = local_resource.lines.get(id)
if settings.get_runtime_value("include_all_responses", false) or data.get("condition") == null or check(data.get("condition")):
var response = DialogueResponse.new(data, auto_translate)
response.character = get_with_replacements(response.character, response.character_replacements)
response.prompt = get_with_replacements(response.prompt, response.replacements)
response.is_allowed = data.get("condition") == null or check(data.get("condition"))
# Add as a child so that it gets cleaned up automatically
_trash.add_child(response)
responses.append(response)
return responses
# Get a value on the current scene or game state
func get_state_value(property: String):
# It's a variable
for state in get_game_states():
if has_property(state, property):
return state.get(property)
printerr("'" + property + "' is not a property on any game states (" + str(get_game_states()) + ").")
assert(false, "Missing property on current scene or game state. See Output for details.")
# Set a value on the current scene or game state
func set_state_value(property: String, value) -> void:
for state in get_game_states():
if has_property(state, property):
state.set(property, value)
return
printerr("'" + property + "' is not a property on any game states (" + str(get_game_states()) + ").")
assert(false, "Missing property on current scene or game state. See Output for details.")
# Collapse any expressions
func resolve(tokens: Array):
# Handle groups first
for token in tokens:
if token.get("type") == DialogueConstants.TOKEN_GROUP:
token["type"] = "value"
token["value"] = resolve(token.get("value"))
# Then variables/methods
var i = 0
var limit = 0
while i < tokens.size() and limit < 1000:
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_FUNCTION:
var function_name = token.get("function")
var args = resolve_each(token.get("value"))
if function_name == "str":
token["type"] = "value"
token["value"] = str(args[0])
elif tokens[i - 1].get("type") == DialogueConstants.TOKEN_DOT:
# If we are calling a deeper function then we need to collapse the
# value into the thing we are calling the function on
var caller = tokens[i - 2]
if not caller.get("value").has_method(function_name):
printerr("\"%s\" is not a callable method on \"%s\"" % [function_name, str(caller)])
assert(false, "Missing callable method on calling object. See Output for details.")
caller["type"] = "value"
caller["value"] = caller.get("value").callv(function_name, args)
tokens.remove(i)
tokens.remove(i-1)
i -= 2
else:
var found = false
for state in get_game_states():
if state.has_method(function_name):
token["type"] = "value"
token["value"] = state.callv(function_name, args)
found = true
if not found:
printerr("\"%s\" is not a method on any game states (%s)" % [function_name, str(get_game_states())])
assert(false, "Missing function on current scene or game state. See Output for details.")
elif token.get("type") == DialogueConstants.TOKEN_DICTIONARY_REFERENCE:
var value = get_state_value(token.get("variable"))
var index = resolve(token.get("value"))
if typeof(value) == TYPE_DICTIONARY:
if tokens.size() > i + 1 and tokens[i + 1].get("type") == DialogueConstants.TOKEN_ASSIGNMENT:
# If the next token is an assignment then we need to leave this as a reference
# so that it can be resolved once everything ahead of it has been resolved
token["type"] = "dictionary"
token["value"] = value
token["key"] = index
else:
if value.has(index):
token["type"] = "value"
token["value"] = value[index]
else:
printerr("Key \"%s\" not found in dictionary \"%s\"" % [str(index), token.get("variable")])
assert(false, "Key not found in dictionary. See Output for details.")
elif typeof(value) == TYPE_ARRAY:
if tokens.size() > i + 1 and tokens[i + 1].get("type") == DialogueConstants.TOKEN_ASSIGNMENT:
# If the next token is an assignment then we need to leave this as a reference
# so that it can be resolved once everything ahead of it has been resolved
token["type"] = "array"
token["value"] = value
token["key"] = index
else:
if index >= 0 and index < value.size():
token["type"] = "value"
token["value"] = value[index]
else:
printerr("Index %d out of bounds of array \"%s\"" % [index, token.get("variable")])
assert(false, "Index out of bounds of array. See Output for details.")
elif token.get("type") == DialogueConstants.TOKEN_ARRAY:
token["type"] = "value"
token["value"] = resolve_each(token.get("value"))
elif token.get("type") == DialogueConstants.TOKEN_DICTIONARY:
token["type"] = "value"
var dictionary = {}
for key in token.get("value").keys():
var resolved_key = resolve([key])
var resolved_value = resolve([token.get("value").get(key)])
dictionary[resolved_key] = resolved_value
token["value"] = dictionary
elif token.get("type") == DialogueConstants.TOKEN_VARIABLE:
if token.get("value") == "null":
token["type"] = "value"
token["value"] = null
elif tokens[i - 1].get("type") == DialogueConstants.TOKEN_DOT:
var caller = tokens[i - 2]
var property = token.get("value")
if tokens.size() > i + 1 and tokens[i + 1].get("type") == DialogueConstants.TOKEN_ASSIGNMENT:
# If the next token is an assignment then we need to leave this as a reference
# so that it can be resolved once everything ahead of it has been resolved
caller["type"] = "property"
caller["property"] = property
else:
# If we are requesting a deeper property then we need to collapse the
# value into the thing we are referencing from
caller["type"] = "value"
caller["value"] = caller.get("value").get(property)
tokens.remove(i)
tokens.remove(i-1)
i -= 2
elif tokens.size() > i + 1 and tokens[i + 1].get("type") == DialogueConstants.TOKEN_ASSIGNMENT:
# It's a normal variable but we will be assigning to it so don't resolve
# it until everything after it has been resolved
token["type"] = "variable"
else:
token["type"] = "value"
token["value"] = get_state_value(token.get("value"))
i += 1
# Then multiply and divide
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_OPERATOR and token.get("value") in ["*", "/", "%"]:
token["type"] = "value"
token["value"] = apply_operation(token.get("value"), tokens[i-1].get("value"), tokens[i+1].get("value"))
tokens.remove(i+1)
tokens.remove(i-1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
# Then addition and subtraction
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_OPERATOR and token.get("value") in ["+", "-"]:
token["type"] = "value"
token["value"] = apply_operation(token.get("value"), tokens[i-1].get("value"), tokens[i+1].get("value"))
tokens.remove(i+1)
tokens.remove(i-1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
# Then negations
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_NOT:
token["type"] = "value"
token["value"] = not tokens[i+1].get("value")
tokens.remove(i+1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
# Then comparisons
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_COMPARISON:
token["type"] = "value"
token["value"] = compare(token.get("value"), tokens[i-1].get("value"), tokens[i+1].get("value"))
tokens.remove(i+1)
tokens.remove(i-1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
# Then and/or
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_AND_OR:
token["type"] = "value"
token["value"] = apply_operation(token.get("value"), tokens[i-1].get("value"), tokens[i+1].get("value"))
tokens.remove(i+1)
tokens.remove(i-1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
# Lastly, resolve any assignments
i = 0
limit = 0
while i < tokens.size() and limit < 1000:
limit += 1
var token = tokens[i]
if token.get("type") == DialogueConstants.TOKEN_ASSIGNMENT:
var lhs = tokens[i - 1]
var value
match lhs.get("type"):
"variable":
value = apply_operation(token.get("value"), get_state_value(lhs.get("value")), tokens[i+1].get("value"))
set_state_value(lhs.get("value"), value)
"property":
value = apply_operation(token.get("value"), lhs.get("value").get(lhs.get("property")), tokens[i+1].get("value"))
lhs.get("value").set(lhs.get("property"), value)
"dictionary", "array":
value = apply_operation(token.get("value"), lhs.get("value")[lhs.get("key")], tokens[i+1].get("value"))
lhs.get("value")[lhs.get("key")] = value
_:
assert(false, "Unknown assignment target")
token["type"] = "value"
token["value"] = value
tokens.remove(i+1)
tokens.remove(i-1)
i -= 1
i += 1
if limit >= 1000:
assert(false, "Something went wrong")
return tokens[0].get("value")
func compare(operator: String, first_value, second_value):
match operator:
"in":
if first_value == null or second_value == null:
return false
else:
return first_value in second_value
"<":
if first_value == null:
return true
elif second_value == null:
return false
else:
return first_value < second_value
">":
if first_value == null:
return false
elif second_value == null:
return true
else:
return first_value > second_value
"<=":
if first_value == null:
return true
elif second_value == null:
return false
else:
return first_value <= second_value
">=":
if first_value == null:
return false
elif second_value == null:
return true
else:
return first_value >= second_value
"==":
if first_value == null:
if typeof(second_value) == TYPE_BOOL:
return second_value == false
else:
return false
else:
return first_value == second_value
"!=":
if first_value == null:
if typeof(second_value) == TYPE_BOOL:
return second_value == true
else:
return false
else:
return first_value != second_value
func apply_operation(operator: String, first_value, second_value):
if first_value == null:
if typeof(second_value) == TYPE_BOOL and second_value == true:
return false
else:
return second_value
elif second_value == null:
if typeof(first_value) == TYPE_BOOL and first_value == true:
return false
else:
return first_value
match operator:
"=":
return second_value
"+", "+=":
return first_value + second_value
"-", "-=":
return first_value - second_value
"/", "/=":
return first_value / second_value
"*", "*=":
return first_value * second_value
"%":
return first_value % second_value
"and":
return first_value and second_value
"or":
return first_value or second_value
_:
assert(false, "Unknown operator")
# Check if a dialogue line contains meaninful information
func is_valid(line: DialogueLine) -> bool:
if line.type == DialogueConstants.TYPE_DIALOGUE and line.dialogue == "":
return false
if line.type == DialogueConstants.TYPE_MUTATION and line.mutation == null:
return false
if line.type == DialogueConstants.TYPE_RESPONSE and line.responses.size() == 0:
return false
return true
# Check if a given property exists
func has_property(thing: Object, name: String) -> bool:
if thing == null:
return false
for p in thing.get_property_list():
if _node_properties.has(p.name):
# Ignore any properties on the base Node
continue
if p.name == name:
return true
return false
func cleanup() -> void:
for line in _trash.get_children():
line.queue_free()