pir-serious-game-ethics/addons/dialogic/Nodes/dialog_node.gd
2021-04-12 10:26:50 +02:00

819 lines
26 KiB
GDScript

tool
extends Control
var last_mouse_mode = null
var input_next: String = 'ui_accept'
var dialog_index: int = 0
var finished: bool = false
var waiting_for_answer: bool = false
var waiting_for_input: bool = false
var waiting: bool = false
var preview: bool = false
var definitions: Dictionary = {}
var definition_visible: bool = false
var settings: ConfigFile
var current_theme: ConfigFile
var current_timeline: String = ''
## The timeline to load when starting the scene
export(String, "TimelineDropdown") var timeline: String
## Should we clear saved data (definitions and timeline progress) on start?
export(bool) var reset_saves = true
## Should we show debug information when running?
export(bool) var debug_mode = true
signal event_start(type, event)
signal event_end(type)
signal dialogic_signal(value)
var dialog_resource
var characters
onready var ChoiceButton = load("res://addons/dialogic/Nodes/ChoiceButton.tscn")
onready var Portrait = load("res://addons/dialogic/Nodes/Portrait.tscn")
var dialog_script: Dictionary = {}
var questions #for keeping track of the questions answered
func _ready():
# Loading the config files
load_config_files()
# Checking if the dialog should read the code from a external file
if not timeline.empty():
dialog_script = set_current_dialog(timeline)
elif dialog_script.keys().size() == 0:
dialog_script = {
"events":[{"character":"","portrait":"",
"text":"[Dialogic Error] No timeline specified."}]
}
# Connecting resize signal
get_viewport().connect("size_changed", self, "resize_main")
resize_main()
# Setting everything up for the node to be default
$DefinitionInfo.visible = false
$TextBubble.connect("text_completed", self, "_on_text_completed")
# Getting the character information
characters = DialogicUtil.get_character_list()
if Engine.is_editor_hint():
if preview:
load_dialog()
else:
load_dialog()
func load_config_files():
if not Engine.is_editor_hint():
if reset_saves:
DialogicSingleton.init(reset_saves)
definitions = DialogicSingleton.get_definitions()
else:
definitions = DialogicResources.get_default_definitions()
settings = DialogicResources.get_settings_config()
var theme_file = 'res://addons/dialogic/Editor/ThemeEditor/default-theme.cfg'
if settings.has_section('theme'):
theme_file = settings.get_value('theme', 'default')
current_theme = load_theme(theme_file)
func resize_main():
# This function makes sure that the dialog is displayed at the correct
# size and position in the screen.
if Engine.is_editor_hint() == false:
set_global_position(Vector2(0,0))
if ProjectSettings.get_setting("display/window/stretch/mode") != '2d':
set_deferred('rect_size', get_viewport().size)
dprint("Viewport", get_viewport().size)
$TextBubble.rect_position.x = (rect_size.x / 2) - ($TextBubble.rect_size.x / 2)
if current_theme != null:
$TextBubble.rect_position.y = (rect_size.y) - ($TextBubble.rect_size.y) - current_theme.get_value('box', 'bottom_gap', 40)
func set_current_dialog(dialog_path: String):
current_timeline = dialog_path
var dialog_script = DialogicResources.get_timeline_json(dialog_path)
# All this parse events should be happening in the same loop ideally
# But until performance is not an issue I will probably stay lazy
# And keep adding different functions for each parsing operation.
if settings.has_section_key('dialog', 'auto_color_names'):
if settings.get_value('dialog', 'auto_color_names'):
dialog_script = parse_characters(dialog_script)
else:
dialog_script = parse_characters(dialog_script)
dialog_script = parse_text_lines(dialog_script)
dialog_script = parse_branches(dialog_script)
return dialog_script
func parse_characters(dialog_script):
var names = DialogicUtil.get_character_list()
# I should use regex here, but this is way easier :)
if names.size() > 0:
var index = 0
for t in dialog_script['events']:
if t.has('text'):
for n in names:
if n.has('name'):
dialog_script['events'][index]['text'] = t['text'].replace(n['name'],
'[color=#' + n['color'].to_html() + ']' + n['name'] + '[/color]'
)
index += 1
return dialog_script
func parse_text_lines(unparsed_dialog_script: Dictionary) -> Dictionary:
var parsed_dialog: Dictionary = unparsed_dialog_script
var new_events: Array = []
var split_new_lines = true
var remove_empty_messages = true
# Return the same thing if it doesn't have events
if unparsed_dialog_script.has('events') == false:
return unparsed_dialog_script
# Getting extra settings
if settings.has_section_key('dialog', 'remove_empty_messages'):
remove_empty_messages = settings.get_value('dialog', 'remove_empty_messages')
if settings.has_section_key('dialog', 'new_lines'):
split_new_lines = settings.get_value('dialog', 'new_lines')
# Parsing
for event in unparsed_dialog_script['events']:
if event.has('text') and event.has('character') and event.has('portrait'):
if event['text'] == '' and remove_empty_messages == true:
pass
elif '\n' in event['text'] and preview == false and split_new_lines == true:
var lines = event['text'].split('\n')
var i = 0
for line in lines:
var _e = {
'text': lines[i],
'character': event['character'],
'portrait': event['portrait']
}
new_events.append(_e)
i += 1
else:
new_events.append(event)
else:
new_events.append(event)
parsed_dialog['events'] = new_events
return parsed_dialog
func parse_alignment(text):
var alignment = current_theme.get_value('text', 'alignment', 'Left')
var fname = current_theme.get_value('settings', 'name', 'none')
if alignment == 'Center':
text = '[center]' + text + '[/center]'
elif alignment == 'Right':
text = '[right]' + text + '[/right]'
return text
func parse_branches(dialog_script: Dictionary) -> Dictionary:
questions = [] # Resetting the questions
# Return the same thing if it doesn't have events
if dialog_script.has('events') == false:
return dialog_script
var parser_queue = [] # This saves the last question opened, and it gets removed once it was consumed by a endbranch event
var event_id: int = 0 # The current id for jumping later on
var question_id: int = 0 # identifying the questions to assign options to it
for event in dialog_script['events']:
if event.has('question'):
event['event_id'] = event_id
event['question_id'] = question_id
event['answered'] = false
question_id += 1
questions.append(event)
parser_queue.append(event)
if event.has('condition'):
event['event_id'] = event_id
event['question_id'] = question_id
event['answered'] = false
question_id += 1
questions.append(event)
parser_queue.append(event)
if event.has('choice'):
var opened_branch = parser_queue.back()
dialog_script['events'][opened_branch['event_id']]['options'].append({
'question_id': opened_branch['question_id'],
'label': event['choice'],
'event_id': event_id,
})
event['question_id'] = opened_branch['question_id']
if event.has('endbranch'):
event['event_id'] = event_id
var opened_branch = parser_queue.pop_back()
event['end_branch_of'] = opened_branch['question_id']
dialog_script['events'][opened_branch['event_id']]['end_id'] = event_id
event_id += 1
return dialog_script
func parse_definitions(text: String, variables: bool = true, glossary: bool = true):
var final_text: String = text
if variables:
final_text = _insert_variable_definitions(text)
if glossary:
final_text = _insert_glossary_definitions(final_text)
return final_text
func _insert_variable_definitions(text: String):
var final_text := text;
for d in definitions['variables']:
var name : String = d['name'];
final_text = final_text.replace('[' + name + ']', d['value'])
return final_text;
func _insert_glossary_definitions(text: String):
var color = current_theme.get_value('definitions', 'color', '#ffbebebe')
var final_text := text;
# I should use regex here, but this is way easier :)
for d in definitions['glossary']:
final_text = final_text.replace(d['name'],
'[url=' + d['id'] + ']' +
'[color=' + color + ']' + d['name'] + '[/color]' +
'[/url]'
)
return final_text;
func _process(delta):
$TextBubble/NextIndicator.visible = finished
if waiting_for_answer and Input.is_action_just_released(input_next):
if $Options.get_child_count() > 0:
$Options.get_child(0).grab_focus()
func _input(event: InputEvent) -> void:
if not Engine.is_editor_hint() and event.is_action_pressed(input_next) and not waiting:
if not $TextBubble.is_finished():
# Skip to end if key is pressed during the text animation
$TextBubble.skip()
else:
if waiting_for_answer == false and waiting_for_input == false:
load_dialog()
if settings.has_section_key('dialog', 'propagate_input'):
var propagate_input: bool = settings.get_value('dialog', 'propagate_input')
if not propagate_input:
get_tree().set_input_as_handled()
func show_dialog():
visible = true
func update_name(character) -> void:
if character.has('name'):
var parsed_name = character['name']
var color = Color.white
if character.has('display_name'):
if character['display_name'] != '':
parsed_name = character['display_name']
if character.has('color'):
color = character['color']
parsed_name = parse_definitions(parsed_name, true, false)
$TextBubble.update_name(parsed_name, color, current_theme.get_value('name', 'auto_color', true))
else:
$TextBubble.update_name('')
func update_text(text: String) -> String:
var final_text = parse_definitions(parse_alignment(text))
final_text = final_text.replace('[br]', '\n')
$TextBubble.update_text(final_text)
return final_text
func _on_text_completed():
finished = true
func on_timeline_start():
if not Engine.is_editor_hint():
DialogicSingleton.save_definitions()
DialogicSingleton.set_current_timeline(current_timeline)
emit_signal("event_start", "timeline", current_timeline)
func on_timeline_end():
if not Engine.is_editor_hint():
DialogicSingleton.save_definitions()
DialogicSingleton.set_current_timeline('')
emit_signal("event_end", "timeline")
func load_dialog(skip_add = false):
# Emitting signals
if dialog_script.has('events'):
if dialog_index == 0:
on_timeline_start()
elif dialog_index == dialog_script['events'].size():
on_timeline_end()
# Hiding definitions popup
definition_visible = false
$DefinitionInfo.visible = definition_visible
# This will load the next entry in the dialog_script array.
if dialog_script.has('events'):
if dialog_index < dialog_script['events'].size():
var func_state = event_handler(dialog_script['events'][dialog_index])
if (func_state is GDScriptFunctionState):
yield(func_state, "completed")
else:
if Engine.is_editor_hint() == false:
queue_free()
if skip_add == false:
dialog_index += 1
func reset_dialog_extras():
$TextBubble/NameLabel.text = ''
$TextBubble/NameLabel.visible = false
func get_character(character_id):
for c in characters:
if c['file'] == character_id:
return c
return {}
func event_handler(event: Dictionary):
# Handling an event and updating the available nodes accordingly.
$TextBubble.reset()
reset_options()
dprint('[D] Current Event: ', event)
match event:
{'text', 'character', 'portrait'}:
emit_signal("event_start", "text", event)
show_dialog()
finished = false
var character_data = get_character(event['character'])
update_name(character_data)
grab_portrait_focus(character_data, event)
update_text(event['text'])
{'question', 'question_id', 'options', ..}:
emit_signal("event_start", "question", event)
show_dialog()
finished = false
waiting_for_answer = true
if event.has('name'):
update_name(event['name'])
update_text(event['question'])
if event.has('options'):
for o in event['options']:
add_choice_button(o)
{'choice', 'question_id'}:
emit_signal("event_start", "choice", event)
for q in questions:
if q['question_id'] == event['question_id']:
if q['answered']:
# If the option is for an answered question, skip to the end of it.
dialog_index = q['end_id']
load_dialog(true)
{'action', ..}:
emit_signal("event_start", "action", event)
if event['action'] == 'leaveall':
if event['character'] == '[All]':
characters_leave_all()
else:
for p in $Portraits.get_children():
if p.character_data['file'] == event['character']:
p.fade_out()
go_to_next_event()
elif event['action'] == 'join':
if event['character'] == '':
go_to_next_event()
else:
var character_data = get_character(event['character'])
var exists = grab_portrait_focus(character_data)
if exists == false:
var p = Portrait.instance()
var char_portrait = event['portrait']
if char_portrait == '':
char_portrait = 'Default'
p.character_data = character_data
p.init(char_portrait, get_character_position(event['position']))
$Portraits.add_child(p)
p.fade_in()
go_to_next_event()
{'scene'}:
get_tree().change_scene(event['scene'])
{'background'}:
emit_signal("event_start", "background", event)
var background = get_node_or_null('Background')
if event['background'] == '' and background != null:
background.queue_free()
else:
if background == null:
background = TextureRect.new()
background.expand = true
background.name = 'Background'
background.anchor_right = 1
background.anchor_bottom = 1
background.stretch_mode = TextureRect.STRETCH_SCALE
background.show_behind_parent = true
add_child(background)
background.texture = null
if (background.get_child_count() > 0):
for c in background.get_children():
c.get_parent().remove_child(c)
c.queue_free()
if (event['background'].ends_with('.tscn')):
var bg_scene = load(event['background'])
if (bg_scene):
bg_scene = bg_scene.instance()
background.add_child(bg_scene)
elif (event['background'] != ''):
background.texture = load(event['background'])
go_to_next_event()
{'audio'}, {'audio', 'file'}:
emit_signal("event_start", "audio", event)
if event['audio'] == 'play' and 'file' in event.keys() and not event['file'].empty():
var audio = get_node_or_null('AudioEvent')
if audio == null:
audio = AudioStreamPlayer.new()
audio.name = 'AudioEvent'
add_child(audio)
audio.stream = load(event['file'])
audio.play()
print('play')
else:
var audio = get_node_or_null('AudioEvent')
if audio != null:
audio.stop()
audio.queue_free()
go_to_next_event()
{'background-music'}, {'background-music', 'file'}:
emit_signal("event_start", "background-music", event)
if event['background-music'] == 'play' and 'file' in event.keys() and not event['file'].empty():
$FX/BackgroundMusic.crossfade_to(event['file'])
else:
$FX/BackgroundMusic.fade_out()
go_to_next_event()
{'endbranch', ..}:
emit_signal("event_start", "endbranch", event)
go_to_next_event()
{'change_scene'}:
get_tree().change_scene(event['change_scene'])
{'emit_signal', ..}:
dprint('[!] Emitting signal: dialogic_signal(', event['emit_signal'], ')')
emit_signal("dialogic_signal", event['emit_signal'])
go_to_next_event()
{'close_dialog'}:
emit_signal("event_start", "close_dialog", event)
close_dialog_event()
{'set_theme'}:
emit_signal("event_start", "set_theme", event)
if event['set_theme'] != '':
current_theme = load_theme(event['set_theme'])
go_to_next_event()
{'wait_seconds'}:
emit_signal("event_start", "wait", event)
wait_seconds(event['wait_seconds'])
waiting = true
{'change_timeline'}:
dialog_script = set_current_dialog(event['change_timeline'])
dialog_index = -1
go_to_next_event()
{'condition', 'definition', 'value', 'question_id', ..}:
# Treating this conditional as an option on a regular question event
var def_value = null
var current_question = questions[event['question_id']]
for d in definitions['variables']:
if d['id'] == event['definition']:
def_value = d['value']
var condition_met = def_value != null and _compare_definitions(def_value, event['value'], event['condition']);
current_question['answered'] = !condition_met
if !condition_met:
# condition not met, skipping branch
dialog_index = current_question['end_id']
load_dialog(true)
else:
# condition met, entering branch
go_to_next_event()
{'set_value', 'definition', ..}:
emit_signal("event_start", "set_value", event)
var operation = '='
if 'operation' in event and not event['operation'].empty():
operation = event["operation"]
DialogicSingleton.set_variable_from_id(event['definition'], event['set_value'], operation)
go_to_next_event()
{'call_node', ..}:
dprint('[!] Call Node signal: dialogic_signal(call_node) ', var2str(event['call_node']))
emit_signal("event_start", "call_node", event)
$TextBubble.visible = false
waiting = true
var target = get_node_or_null(event['call_node']['target_node_path'])
var method_name = event['call_node']['method_name']
var args = event['call_node']['arguments']
if (not args is Array):
args = []
if (target != null):
if (target.has_method(method_name)):
if (args.empty()):
var func_result = target.call(method_name)
if (func_result is GDScriptFunctionState):
yield(func_result, "completed")
else:
var func_result = target.call(method_name, args)
if (func_result is GDScriptFunctionState):
yield(func_result, "completed")
waiting = false
$TextBubble.visible = true
go_to_next_event()
_:
visible = false
dprint('Other event. ', event)
$Options.visible = waiting_for_answer
func reset_options():
# Clearing out the options after one was selected.
for option in $Options.get_children():
option.queue_free()
func add_choice_button(option):
var theme = current_theme
var button = ChoiceButton.instance()
button.text = option['label']
# Text
button.set('custom_fonts/font', DialogicUtil.path_fixer_load(theme.get_value('text', 'font', "res://addons/dialogic/Example Assets/Fonts/DefaultFont.tres")))
if not theme.get_value('buttons', 'use_native', false):
var text_color = Color(theme.get_value('text', 'color', "#ffffffff"))
button.set('custom_colors/font_color', text_color)
button.set('custom_colors/font_color_hover', text_color)
button.set('custom_colors/font_color_pressed', text_color)
if theme.get_value('buttons', 'text_color_enabled', true):
var button_text_color = Color(theme.get_value('buttons', 'text_color', "#ffffffff"))
button.set('custom_colors/font_color', button_text_color)
button.set('custom_colors/font_color_hover', button_text_color)
button.set('custom_colors/font_color_pressed', button_text_color)
# Background
button.get_node('ColorRect').color = Color(theme.get_value('buttons', 'background_color', '#ff000000'))
button.get_node('ColorRect').visible = theme.get_value('buttons', 'use_background_color', false)
button.get_node('TextureRect').visible = theme.get_value('buttons', 'use_image', true)
if theme.get_value('buttons', 'use_image', true):
button.get_node('TextureRect').texture = DialogicUtil.path_fixer_load(theme.get_value('buttons', 'image', "res://addons/dialogic/Example Assets/backgrounds/background-2.png"))
if theme.get_value('buttons', 'modulation', false):
button.get_node('TextureRect').modulate = Color(theme.get_value('buttons', 'modulation_color', "#ffffffff"))
var padding = theme.get_value('buttons', 'padding', Vector2(5,5))
button.get_node('ColorRect').set('margin_left', -1 * padding.x)
button.get_node('ColorRect').set('margin_right', padding.x)
button.get_node('ColorRect').set('margin_top', -1 * padding.y)
button.get_node('ColorRect').set('margin_bottom', padding.y)
button.get_node('TextureRect').set('margin_left', -1 * padding.x)
button.get_node('TextureRect').set('margin_right', padding.x)
button.get_node('TextureRect').set('margin_top', -1 * padding.y)
button.get_node('TextureRect').set('margin_bottom', padding.y)
$Options.set('custom_constants/separation', theme.get_value('buttons', 'gap', 20) + (padding.y*2))
else:
button.get_node('ColorRect').visible = false
button.get_node('TextureRect').visible = false
button.set_flat(false)
$Options.set('custom_constants/separation', theme.get_value('buttons', 'gap', 20))
button.connect("pressed", self, "answer_question", [button, option['event_id'], option['question_id']])
$Options.add_child(button)
if Input.get_mouse_mode() != Input.MOUSE_MODE_VISIBLE:
last_mouse_mode = Input.get_mouse_mode()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) # Make sure the cursor is visible for the options selection
func answer_question(i, event_id, question_id):
dprint('[!] Going to ', event_id + 1, i, 'question_id:', question_id)
dprint('')
waiting_for_answer = false
dialog_index = event_id + 1
questions[question_id]['answered'] = true
dprint(' dialog_index = ', dialog_index)
reset_options()
load_dialog()
if last_mouse_mode != null:
Input.set_mouse_mode(last_mouse_mode) # Revert to last mouse mode when selection is done
last_mouse_mode = null
func _on_option_selected(option, variable, value):
dialog_resource.custom_variables[variable] = value
waiting_for_answer = false
reset_options()
load_dialog()
dprint('[!] Option selected: ', option.text, ' value= ' , value)
func go_to_next_event():
# The entire event reading system should be refactored... but not today!
dialog_index += 1
load_dialog(true)
func grab_portrait_focus(character_data, event: Dictionary = {}) -> bool:
var exists = false
var visually_focus = true
if settings.has_section_key('dialog', 'dim_characters'):
visually_focus = settings.get_value('dialog', 'dim_characters')
for portrait in $Portraits.get_children():
if portrait.character_data == character_data:
exists = true
if visually_focus:
portrait.focus()
if event.has('portrait'):
if event['portrait'] != '':
portrait.set_portrait(event['portrait'])
else:
if visually_focus:
portrait.focusout()
return exists
func get_character_position(positions) -> String:
if positions['0']:
return 'left'
if positions['1']:
return 'center_left'
if positions['2']:
return 'center'
if positions['3']:
return 'center_right'
if positions['4']:
return 'right'
return 'left'
func deferred_resize(current_size, result):
#var result = theme.get_value('box', 'size', Vector2(910, 167))
$TextBubble.rect_size = result
if current_size != $TextBubble.rect_size:
resize_main()
func load_theme(filename):
var theme = DialogicResources.get_theme_config(filename)
# Box size
call_deferred('deferred_resize', $TextBubble.rect_size, theme.get_value('box', 'size', Vector2(910, 167)))
input_next = theme.get_value('settings', 'action_key', 'ui_accept')
# Definitions
var definitions_font = DialogicUtil.path_fixer_load(theme.get_value('definitions', 'font', "res://addons/dialogic/Example Assets/Fonts/GlossaryFont.tres"))
$DefinitionInfo/VBoxContainer/Title.set('custom_fonts/normal_font', definitions_font)
$DefinitionInfo/VBoxContainer/Content.set('custom_fonts/normal_font', definitions_font)
$DefinitionInfo/VBoxContainer/Extra.set('custom_fonts/normal_font', definitions_font)
$TextBubble.load_theme(theme)
return theme
func _on_RichTextLabel_meta_hover_started(meta):
var correct_type = false
for d in definitions['glossary']:
if d['id'] == meta:
$DefinitionInfo.load_preview({
'title': d['title'],
'body': d['text'],
'extra': d['extra'],
'color': current_theme.get_value('definitions', 'color', '#ffbebebe'),
})
correct_type = true
dprint(d)
if correct_type:
definition_visible = true
$DefinitionInfo.visible = definition_visible
# Adding a timer to avoid a graphical glitch
$DefinitionInfo/Timer.stop()
func _on_RichTextLabel_meta_hover_ended(meta):
# Adding a timer to avoid a graphical glitch
$DefinitionInfo/Timer.start(0.1)
func _on_Definition_Timer_timeout():
# Adding a timer to avoid a graphical glitch
definition_visible = false
$DefinitionInfo.visible = definition_visible
func wait_seconds(seconds):
var timer = Timer.new()
timer.name = 'WaitSeconds'
add_child(timer)
timer.connect("timeout", self, '_on_WaitSeconds_timeout')
timer.start(seconds)
$TextBubble.visible = false
func _on_WaitSeconds_timeout():
emit_signal("event_end", "wait")
waiting = false
$WaitSeconds.stop()
$WaitSeconds.queue_free()
$TextBubble.visible = true
load_dialog()
func dprint(string, arg1='', arg2='', arg3='', arg4='' ):
# HAHAHA if you are here wondering what this is...
# I ask myself the same question :')
if debug_mode:
print(str(string) + str(arg1) + str(arg2) + str(arg3) + str(arg4))
func _compare_definitions(def_value: String, event_value: String, condition: String):
var condition_met = false;
if def_value != null and event_value != null:
# check if event_value equals a definition name and use that instead
for d in definitions['variables']:
if (d['name'] != '' and d['name'] == event_value):
event_value = d['value']
break;
var converted_def_value = def_value
var converted_event_value = event_value
if def_value.is_valid_float() and event_value.is_valid_float():
converted_def_value = float(def_value)
converted_event_value = float(event_value)
match condition:
"==":
condition_met = converted_def_value == converted_event_value
"!=":
condition_met = converted_def_value != converted_event_value
">":
condition_met = converted_def_value > converted_event_value
">=":
condition_met = converted_def_value >= converted_event_value
"<":
condition_met = converted_def_value < converted_event_value
"<=":
condition_met = converted_def_value <= converted_event_value
return condition_met
func characters_leave_all():
var portraits = get_node_or_null('Portraits')
if portraits != null:
for p in portraits.get_children():
p.fade_out()
func close_dialog_event():
var tween = Tween.new()
add_child(tween)
tween.interpolate_property($TextBubble, "modulate",
$TextBubble.modulate, Color('#00ffffff'), 1,
Tween.TRANS_LINEAR, Tween.EASE_IN_OUT)
tween.start()
var close_dialog_timer = Timer.new()
close_dialog_timer.connect("timeout", self, '_on_close_dialog_timeout')
add_child(close_dialog_timer)
close_dialog_timer.start(2)
characters_leave_all()
func _on_close_dialog_timeout():
on_timeline_end()
queue_free()