Add addons

This commit is contained in:
Joey Yakimowich-Payne 2023-01-03 16:53:53 -07:00
commit 3367bad24f
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
527 changed files with 36197 additions and 0 deletions

View file

@ -0,0 +1,556 @@
extends Node
enum states {IDLE, SHOWING_TEXT, ANIMATING, AWAITING_CHOICE, WAITING}
var current_timeline: Variant = null
var current_timeline_events: Array = []
var timeline_jump_stack: Array = []
var character_directory: Dictionary = {}
var timeline_directory: Dictionary = {}
var _event_script_cache: Array[DialogicEvent] = []
var current_state: Variant = null:
get:
return current_state
set(new_state):
current_state = new_state
emit_signal('state_changed', new_state)
var paused := false:
set(value):
paused = value
if paused:
for subsystem in get_children():
if subsystem.has_method('pause'):
subsystem.pause()
dialogic_paused.emit()
else:
for subsystem in get_children():
if subsystem.has_method('resume'):
subsystem.resume()
dialogic_resumed.emit()
signal dialogic_paused
signal dialogic_resumed
var current_event_idx: int = 0
var current_state_info: Dictionary = {}
signal state_changed(new_state)
signal timeline_ended()
signal timeline_started()
signal event_handled(resource)
signal signal_event(argument)
signal text_signal(argument)
func _ready() -> void:
rebuild_character_directory()
rebuild_timeline_directory()
collect_subsystems()
clear()
################################################################################
## TIMELINE+EVENT HANDLING
################################################################################
func start_timeline(timeline_resource:Variant, label_or_idx:Variant = "") -> void:
# load the resource if only the path is given
if typeof(timeline_resource) == TYPE_STRING:
#check the lookup table if it's not a full file name
if timeline_resource.contains("res://"):
timeline_resource = load(timeline_resource)
else:
timeline_resource = load(find_timeline(timeline_resource))
if timeline_resource == null:
assert(false, "There was an error loading this timeline. Check the filename, and the timeline for errors")
timeline_resource = process_timeline(timeline_resource)
current_timeline = timeline_resource
current_timeline_events = current_timeline.get_events()
current_event_idx = -1
if typeof(label_or_idx) == TYPE_STRING:
if label_or_idx:
jump_to_label(label_or_idx)
elif typeof(label_or_idx) == TYPE_INT:
if label_or_idx >-1:
current_event_idx = label_or_idx -1
emit_signal('timeline_started')
handle_next_event()
# Preloader function, prepares a timeline and returns an object to hold for later
## TODO: Question: why is this taking a variant and then only allowing a string?
func preload_timeline(timeline_resource:Variant) -> Variant:
# I think ideally this should be on a new thread, will test
if typeof(timeline_resource) == TYPE_STRING:
timeline_resource = load(timeline_resource)
if timeline_resource == null:
assert(false, "There was an error loading this timeline. Check the filename, and the timeline for errors")
else:
timeline_resource = process_timeline(timeline_resource)
return timeline_resource
return false
func end_timeline() -> void:
current_timeline = null
current_timeline_events = []
clear()
emit_signal("timeline_ended")
func handle_next_event(ignore_argument:Variant = "") -> void:
handle_event(current_event_idx+1)
func handle_event(event_index:int) -> void:
if not current_timeline:
return
if paused:
await dialogic_resumed
if event_index >= len(current_timeline_events):
if timeline_jump_stack.size() > 0:
pop_from_jump_stack()
event_index = current_event_idx+1
else:
end_timeline()
return
#actually process the event now, since we didnt earlier at runtime
#this needs to happen before we create the copy DialogicEvent variable, so it doesn't throw an error if not ready
if current_timeline_events[event_index]['event_node_ready'] == false:
current_timeline_events[event_index]._load_from_string(current_timeline_events[event_index]['event_node_as_text'])
current_event_idx = event_index
#print("\n[D] Handle Event ", event_index, ": ", event)
if current_timeline_events[event_index].continue_at_end:
#print(" -> WILL AUTO CONTINUE!")
if not current_timeline_events[event_index].event_finished.is_connected(handle_next_event):
current_timeline_events[event_index].event_finished.connect(handle_next_event, CONNECT_ONE_SHOT)
if has_subsystem('History'):
self.History.add_event_to_history(current_timeline.resource_path, event_index, current_timeline_events[event_index])
current_timeline_events[event_index].execute(self)
emit_signal('event_handled', current_timeline_events[event_index])
func jump_to_label(label:String) -> void:
if label.is_empty():
current_event_idx = 0
return
var idx: int = -1
while true:
idx += 1
var event: Variant = current_timeline.get_event(idx)
if not event:
idx = current_event_idx
break
if event is DialogicLabelEvent and event.name == label:
break
current_event_idx = idx
func push_to_jump_stack() -> void:
var current_point:Dictionary = {}
current_point['timeline'] = current_timeline
current_point['index'] = current_event_idx
timeline_jump_stack.push_front(current_point)
func pop_from_jump_stack() -> void:
var stack_point:Dictionary = timeline_jump_stack.pop_front()
current_timeline = stack_point['timeline']
current_timeline_events = current_timeline.get_events()
current_event_idx = stack_point['index']
func clear() -> bool:
for subsystem in get_children():
subsystem.clear_game_state()
# Clearing existing Dialogic main nodes
for i in get_tree().get_nodes_in_group('dialogic_main_node'):
i.queue_free()
# Resetting variables
current_timeline = null
current_event_idx = -1
current_timeline_events = []
current_state = states.IDLE
return true
################################################################################
## STATE
################################################################################
func execute_condition(condition:String) -> bool:
var regex: RegEx = RegEx.new()
regex.compile('{(\\w.*)}')
var result := regex.search_all(condition)
if result:
for res in result:
var r_string: String = res.get_string()
var value: String = self.VAR.get_variable(r_string)
if !value.is_valid_float():
value = '"'+value+'"'
condition = condition.replace(r_string, value)
var expr: Expression = Expression.new()
# this doesn't work currently, not sure why. However you can still use autoloads in conditions
# you have to put them in {} brackets tho. E.g. `if {MyAutoload.my_var} > 20:`
# var autoload_names: Array = []
# var autoloads: Array = []
# for c in get_tree().root.get_children():
# autoloads.append(c)
# autoload_names.append(c.name)
# expr.parse(condition, autoload_names)
# if expr.execute(autoloads, self):
expr.parse(condition)
if expr.execute([], self):
return true
if expr.has_execute_failed():
printerr('Dialogic: Condition failed to execute: ', expr.get_error_text())
return false
################################################################################
## SAVING & LOADING
################################################################################
func get_full_state() -> Dictionary:
if current_timeline:
current_state_info['current_event_idx'] = current_event_idx
current_state_info['current_timeline'] = current_timeline.resource_path
else:
current_state_info['current_event_idx'] = -1
current_state_info['current_timeline'] = null
if has_subsystem('Portraits'):
current_state_info['current_portrait_positions'] = self.Portraits.current_positions
current_state_info['default_portrait_positions'] = self.Portraits._default_positions
if has_subsystem('History'):
if self.History.full_history_enabled:
self.History.strip_events_from_full_history()
current_state_info['full_history'] = self.History.full_history
if self.History.text_read_history_enabled:
current_state_info['text_read_history'] = self.History.text_read_history
return current_state_info
func load_full_state(state_info:Dictionary) -> void:
for subsystem in get_children():
subsystem.clear_game_state()
current_state_info = state_info
if current_state_info.get('current_timeline', null):
start_timeline(current_state_info.current_timeline, current_state_info.get('current_event_idx', 0))
if has_subsystem('Portraits'):
if current_state_info.get('current_portrait_positions', null):
self.Portraits.current_positions = current_state_info['current_portrait_positions']
self.Portraits._default_positions = current_state_info['default_portrait_positions']
if has_subsystem('History'):
if self.History.full_history_enabled:
self.History.full_history = current_state_info['full_history']
if self.History.text_read_history_enabled:
self.History.text_read_history = current_state_info['text_read_history']
for subsystem in get_children():
subsystem.load_game_state()
################################################################################
## SUB-SYTSEMS
################################################################################
func collect_subsystems() -> void:
# This also builds the event script cache as well
_event_script_cache = []
for indexer in DialogicUtil.get_indexers():
# build event cache
for event in indexer._get_events():
if not 'event_end_branch.gd' in event and not 'event_text.gd' in event:
_event_script_cache.append(load(event).new())
# build the subsystems (only at runtime)
if !Engine.is_editor_hint():
for subsystem in indexer._get_subsystems():
add_subsytsem(subsystem.name, subsystem.script)
# Events are checked in order while testing them. EndBranch needs to be first, Text needs to be last
_event_script_cache.push_front(DialogicEndBranchEvent.new())
_event_script_cache.push_back(DialogicTextEvent.new())
func has_subsystem(_name:String) -> bool:
return has_node(_name)
func get_subsystem(_name:String) -> Variant:
return get_node(_name)
func add_subsytsem(_name:String, _script_path:String) -> Node:
var node:Node = Node.new()
node.name = _name
node.set_script(load(_script_path))
node.dialogic = self
add_child(node)
return node
func _get(property):
if has_subsystem(property):
return get_node(str(property))
func _set(property, value):
if has_subsystem(property):
return true
################################################################################
## PROCESSING FUNCTIONS
################################################################################
func rebuild_character_directory() -> void:
var characters: Array = DialogicUtil.list_resources_of_type(".dch")
# First sort by length of path, so shorter paths are first
characters.sort_custom(func(a, b):return a.count("/") < b.count("/"))
# next we prepare the additional arrays needed for building the depth tree
var shortened_paths:Array = []
var reverse_array:Array = []
var reverse_array_splits:Array = []
for i in characters.size():
characters[i] = characters[i].replace("res:///", "res://")
var path = characters[i].replace("res://","").replace(".dch", "")
if path[0] == "/":
path = path.right(-1)
shortened_paths.append(path)
#split the shortened path up, and reverse it
var path_breakdown = path.split("/")
path_breakdown.reverse()
#Add the name of the file at beginning now, and another array saving the reversed split within each element
reverse_array.append(path_breakdown[0])
reverse_array_splits.append(path_breakdown)
# Now the three arrays are prepped, begin the depth search
var clean_search_path:bool = false
var depth := 1
while !clean_search_path:
var interim_array:Array = []
clean_search_path = true
for i in shortened_paths.size():
if reverse_array.count(reverse_array[i]) > 1:
clean_search_path = false
if depth < reverse_array_splits[i].size():
interim_array.append(reverse_array_splits[i][depth] + "/" + reverse_array[i])
else:
interim_array.append(reverse_array[i])
else:
interim_array.append(reverse_array[i])
depth += 1
reverse_array = interim_array
# Now finally build the database from those arrays
for i in characters.size():
var entry:Dictionary = {}
var charfile: DialogicCharacter= load(characters[i])
entry['resource'] = charfile
entry['full_path'] = characters[i]
entry['unique_short_path'] = reverse_array[i]
character_directory[reverse_array[i]] = entry
func rebuild_timeline_directory() -> void:
var characters: Array = DialogicUtil.list_resources_of_type(".dtl")
# First sort by length of path, so shorter paths are first
characters.sort_custom(func(a, b):return a.count("/") < b.count("/"))
# next we prepare the additional arrays needed for building the depth tree
var shortened_paths:Array = []
var reverse_array:Array = []
var reverse_array_splits:Array = []
for i in characters.size():
characters[i] = characters[i].replace("res:///", "res://")
var path = characters[i].replace("res://","").replace(".dtl", "")
if path[0] == "/":
path = path.right(-1)
shortened_paths.append(path)
#split the shortened path up, and reverse it
var path_breakdown = path.split("/")
path_breakdown.reverse()
#Add the name of the file at beginning now, and another array saving the reversed split within each element
reverse_array.append(path_breakdown[0])
reverse_array_splits.append(path_breakdown)
# Now the three arrays are prepped, begin the depth search
var clean_search_path:bool = false
var depth = 1
while !clean_search_path:
var interim_array:Array = []
clean_search_path = true
for i in shortened_paths.size():
if reverse_array.count(reverse_array[i]) > 1:
clean_search_path = false
if depth < reverse_array_splits[i].size():
interim_array.append(reverse_array_splits[i][depth] + "/" + reverse_array[i])
else:
interim_array.append(reverse_array[i])
else:
interim_array.append(reverse_array[i])
depth += 1
reverse_array = interim_array
# Now finally build the database from those arrays
for i in characters.size():
timeline_directory[reverse_array[i]] = characters[i]
func find_timeline(path: String) -> String:
if path in timeline_directory.keys():
return timeline_directory[path]
else:
for i in timeline_directory.keys():
if timeline_directory[i].contains(path):
return timeline_directory[i]
return ""
func process_timeline(timeline: DialogicTimeline) -> DialogicTimeline:
if timeline != null:
if timeline.events_processed:
return timeline
else:
#print(str(Time.get_ticks_msec()) + ": Starting process unloaded timeline")
var end_event := DialogicEndBranchEvent.new()
var prev_indent := ""
var events := []
# this is needed to add a end branch event even to empty conditions/choices
var prev_was_opener := false
var lines := timeline.events
var idx := -1
var empty_lines = 0
while idx < len(lines)-1:
idx += 1
## First manage indent and creation of end branch events
## For this, we still treat the event as a string
var line: String = ""
if typeof(lines[idx]) == TYPE_STRING:
line = lines[idx]
else:
line = lines[idx]['event_node_as_text']
var line_stripped :String = line.strip_edges(true, false)
if line_stripped.is_empty():
empty_lines += 1
continue
var indent :String= line.substr(0,len(line)-len(line_stripped))
if len(indent) < len(prev_indent):
for i in range(len(prev_indent)-len(indent)):
events.append(end_event.duplicate())
elif prev_was_opener and len(indent) == len(prev_indent):
events.append(end_event.duplicate())
prev_indent = indent
## Now we process the event into a resource
## by checking on each event if it recognizes this string
var event_content :String = line_stripped
var event :Variant
for i in _event_script_cache:
if i._test_event_string(event_content):
event = i.duplicate()
break
event.empty_lines_above = empty_lines
# add the following lines until the event says it's full there is an empty line or the indent changes
while !event.is_string_full_event(event_content):
idx += 1
if idx == len(lines):
break
var following_line :String = lines[idx]
var following_line_stripped :String = following_line.strip_edges(true, false)
var following_line_indent :String = following_line.substr(0,len(following_line)-len(following_line_stripped))
if following_line_stripped.is_empty():
break
if following_line_indent != indent:
idx -= 1
break
event_content += "\n"+following_line_stripped
if Engine.is_editor_hint():
# Unlike at runtime, for some reason here the event scripts can't access the scene tree to get to the character directory, so we will need to pass it to it before processing
if event['event_name'] == 'Character' || event['event_name'] == 'Text':
event.set_meta('editor_character_directory', character_directory)
event._load_from_string(event_content)
event['event_node_as_text'] = event_content
events.append(event)
prev_was_opener = event.can_contain_events
empty_lines = 0
if !prev_indent.is_empty():
for i in range(len(prev_indent)):
events.append(end_event.duplicate())
timeline.events = events
timeline.events_processed = true
#print(str(Time.get_ticks_msec()) + ": Finished process unloaded timeline")
return timeline
else:
return DialogicTimeline.new()
################################################################################
## FOR END USER
################################################################################
func start(timeline, single_instance = true):
var dialog_scene_path: String = DialogicUtil.get_project_setting(
'dialogic/editor/default_dialog_scene', "res://addons/dialogic/Example Assets/example-scenes/DialogicDefaultScene.tscn")
if single_instance:
if get_tree().get_nodes_in_group('dialogic_main_node').is_empty():
var scene = load(dialog_scene_path).instantiate()
get_parent().call_deferred("add_child", scene)
Dialogic.start_timeline(timeline)
func is_running() -> bool:
if get_tree().get_nodes_in_group('dialogic_main_node').is_empty():
return false
return true

View file

@ -0,0 +1,184 @@
@tool
class_name DialogicUtil
static func get_editor_scale() -> float:
return get_dialogic_plugin().editor_interface.get_editor_scale()
static func listdir(path: String, files_only: bool = true, throw_error:bool = true, full_file_path:bool = false) -> Array:
var files: Array = []
if DirAccess.dir_exists_absolute(path):
var dir := DirAccess.open(path)
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if not file_name.begins_with("."):
if files_only:
if not dir.current_is_dir() and not file_name.ends_with('.import'):
if full_file_path:
files.append(path.path_join(file_name))
else:
files.append(file_name)
else:
if full_file_path:
files.append(path.path_join(file_name))
else:
files.append(file_name)
file_name = dir.get_next()
dir.list_dir_end()
return files
static func get_dialogic_plugin() -> Node:
var tree: SceneTree = Engine.get_main_loop()
if tree.get_root().get_child(0).has_node('DialogicPlugin'):
return tree.get_root().get_child(0).get_node('DialogicPlugin')
return null
static func list_resources_of_type(extension:String) -> Array:
var all_resources := scan_folder('res://', extension)
return all_resources
static func scan_folder(path:String, extension:String) -> Array:
var list: Array = []
if DirAccess.dir_exists_absolute(path):
var dir := DirAccess.open(path)
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if dir.current_is_dir() and not file_name.begins_with("."):
list += scan_folder(path.path_join(file_name), extension)
else:
if file_name.ends_with(extension):
list.append(path.path_join(file_name))
file_name = dir.get_next()
return list
static func guess_resource(extension, identifier):
var resources = list_resources_of_type(extension)
for resource_path in resources:
if resource_path.get_file().trim_suffix(extension) == identifier:
return resource_path
#
#static func get_event_by_string(string:String) -> Resource:
# var event_scripts = get_event_scripts()
#
# # move the text event to the end of the list as it's the default.
# event_scripts.erase("res://addons/dialogic/Events/Text/event_text.gd")
# event_scripts.append("res://addons/dialogic/Events/Text/event_text.gd")
#
# for event in event_scripts:
# if load(event).new()._test_event_string(string):
#
# return load(event)
# return load("res://addons/dialogic/Events/Text/event_text.gd")
static func get_project_setting(setting:String, default = null):
return ProjectSettings.get_setting(setting) if ProjectSettings.has_setting(setting) else default
static func get_indexers(include_custom := true, force_reload := false) -> Array[DialogicIndexer]:
if Engine.get_main_loop().has_meta('dialogic_indexers') and !force_reload:
return Engine.get_main_loop().get_meta('dialogic_indexers')
var indexers := []
for file in listdir("res://addons/dialogic/Events/", false):
var possible_script:String = "res://addons/dialogic/Events/" + file + "/index.gd"
if FileAccess.file_exists(possible_script):
indexers.append(load(possible_script).new())
if include_custom:
for file in listdir("res://addons/dialogic_additions/Events/", false, false):
var possible_script: String = "res://addons/dialogic_additions/Events/" + file + "/index.gd"
if FileAccess.file_exists(possible_script):
indexers.append(load(possible_script).new())
Engine.get_main_loop().set_meta('dialogic_indexers', indexers)
return indexers
static func pretty_name(script:String) -> String:
var _name = script.get_file().trim_suffix("."+script.get_extension())
_name = _name.replace('_', ' ')
_name = _name.capitalize()
return _name
static func str_to_bool(boolstring:String) -> bool:
return true if boolstring == "true" else false
static func logical_convert(value):
if typeof(value) == TYPE_STRING:
if value.is_valid_int():
return value.to_int()
if value.is_valid_float():
return value.to_float()
if value == 'true':
return true
if value == 'false':
return false
return value
static func get_color_palette(default:bool = false) -> Dictionary:
# Colors are using the ProjectSettings instead of the EditorSettings
# because there is a bug in Godot which prevents us from using it.
# When you try to do it, the text in the timelines goes to weird artifacts
# and on the Output panel you see the error:
# ./core/rid.h:151 - Condition "!id_map.has(p_rid.get_data())" is true. Returned: nullptr
# over and over again.
# Might revisit this in Godot 4, but don't have any high hopes for it improving.
var colors = [
Color('#3b8bf2'), # Blue
Color('#00b15f'), # Green
Color('#9468e8'), # Purple
Color('#de5c5c'), # Red
Color('#fa952a'), # Orange
Color('#7C7C7C') # Gray
]
var color_dict = {}
var index = 1
for n in colors:
var color_name = 'Color' + str(index)
color_dict[color_name] = n
if !default:
if ProjectSettings.has_setting('dialogic/editor/' + color_name):
color_dict[color_name] = ProjectSettings.get_setting('dialogic/editor/' + color_name)
index += 1
return color_dict
static func get_color(value:String) -> Color:
var colors = get_color_palette()
return colors[value]
static func is_physics_timer()->bool:
return get_project_setting('dialogic/timer/process_in_physics', false)
static func update_timer_process_callback(timer:Timer) -> void:
timer.process_callback = Timer.TIMER_PROCESS_PHYSICS if is_physics_timer() else Timer.TIMER_PROCESS_IDLE
static func get_next_translation_id() -> String:
ProjectSettings.set_setting('dialogic/translation/id_counter', get_project_setting('dialogic/translation/id_counter', 16)+1)
return '%x' % get_project_setting('dialogic/translation/id_counter', 16)
# helper that converts a nested variable dictionary into an array with paths
static func list_variables(dict:Dictionary, path := "") -> Array:
var array := []
for key in dict.keys():
if typeof(dict[key]) == TYPE_DICTIONARY:
array.append_array(list_variables(dict[key], path+key+"."))
else:
array.append(path+key)
return array

View file

@ -0,0 +1,15 @@
extends Node
class_name DialogicSubsystem
var dialogic = null
# To be overriden by sub-classes
# Fill in everything that should be cleared (for example before loading a different state)
func clear_game_state():
pass
# To be overriden by sub-classes
# Fill in everything that should be loaded using the dialogic_game_handler.current_state_info
# This is called when a save is loaded
func load_game_state():
pass

View file

@ -0,0 +1,27 @@
@tool
class_name DialogicIndexer
extends RefCounted
## Script that indexes events, subsystems, settings pages and more.
## Place a script of this type in every folder in "addons/Events".
## Overwrite the methods to return the contents of that folder.
var this_folder : String = get_script().resource_path.get_base_dir()
func _get_events() -> Array:
if FileAccess.file_exists(this_folder.path_join('event.gd')):
return [this_folder.path_join('event.gd')]
return []
func _get_subsystems() -> Array:
return []
func _get_settings_pages() -> Array:
return []
func _get_character_editor_tabs() -> Array:
return []