feat: allows for the skipping of dialogue after the text is fully visible; still need to look at integrating with user options

This commit is contained in:
Duncan Brown
2022-11-14 22:43:40 -05:00
committed by Julian Murgia
parent 886f402395
commit bd2f28214b
15 changed files with 615 additions and 169 deletions

View File

@@ -6,6 +6,9 @@ class_name ESCDialogManager
# Emitted when the say function has completed showing the text
signal say_finished
# Emitted when text has just become fully visible
signal say_visible
# Emitted when the player has chosen an option
signal option_chosen(option)

View File

@@ -1,12 +1,8 @@
# Escoria dialog player
extends Node
extends StateMachine
class_name ESCDialogPlayer
# A regular expression that separates the translation key from the text
const KEYTEXT_REGEX = "^((?<key>[^:]+):)?\"(?<text>.+)\""
# Emitted when an answer as chosem
#
# ##### Parameters
@@ -18,78 +14,51 @@ signal option_chosen(option)
signal say_finished
# Whether the player is currently speaking
var is_speaking: bool = false
# Reference to the currently playing dialog manager
var _dialog_manager: ESCDialogManager = null
# Regular expression object for the separation of key and text
var _keytext_regex: RegEx = RegEx.new()
# Constructor
func _init() -> void:
_keytext_regex.compile(KEYTEXT_REGEX)
# Register the dialog player and load the dialog resources
func _ready():
if !Engine.is_editor_hint():
escoria.dialog_player = self
if Engine.is_editor_hint():
return
escoria.dialog_player = self
_create_states()
_add_states_to_machine()
states_map["say"].connect("dialog_manager_set", self, "_on_dialog_manager_set")
current_state_name = "idle"
START_STATE = states_map[current_state_name]
initialize(START_STATE)
# Trigger the speedup function in the dialog manager
#
# #### Parameters
#
# - event: The input event
func _input(event):
if event is InputEventMouseButton and event.pressed and is_speaking:
speedup()
get_tree().set_input_as_handled()
# Creates the states for this state machine.
func _create_states() -> void:
states_map = {
"idle": DialogIdle.new(),
"say": DialogSay.new(),
"say_fast": DialogSayFast.new(),
"visible": DialogVisible.new(),
"finish": DialogFinish.new(),
"interrupt": DialogInterrupt.new(),
"choices": DialogChoices.new(),
}
# This state needs a reference to this class.
states_map["finish"].initialize(self)
# Find the matching voice output file for the given key
#
# #### Parameters
#
# - key: Text key provided
# - start: Starting folder to search for voices
#
# *Returns* The path to the matching voice file
func _get_voice_file(key: String, start: String = "") -> String:
if start == "":
start = ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.SPEECH_FOLDER
)
var _dir = Directory.new()
if _dir.open(start) == OK:
_dir.list_dir_begin(true, true)
var file_name = _dir.get_next()
while file_name != "":
if _dir.current_is_dir():
var _voice_file = _get_voice_file(
key,
start.plus_file(file_name)
)
if _voice_file != "":
return _voice_file
else:
if file_name == "%s.%s.import" % [
key,
ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.SPEECH_EXTENSION
)
]:
return start.plus_file(file_name.trim_suffix(".import"))
file_name = _dir.get_next()
return ""
# Adds any created states into the state machine as children.
func _add_states_to_machine() -> void:
for key in states_map:
add_child(states_map[key])
# Make a character say a text
# Make a character say some text
#
# #### Parameters
#
@@ -97,81 +66,13 @@ func _get_voice_file(key: String, start: String = "") -> String:
# - type: UI to use for the dialog
# - text: Text to say
func say(character: String, type: String, text: String) -> void:
if type == "":
type = ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DEFAULT_DIALOG_TYPE
)
is_speaking = true
for _manager_class in ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
var _manager: ESCDialogManager = load(_manager_class).new()
if _manager.has_type(type):
_dialog_manager = _manager
else:
_dialog_manager = null
if _dialog_manager == null:
escoria.logger.error(
self,
"No dialog manager called %s configured." % type
)
_dialog_manager.connect("say_finished", self, "_on_say_finished", [], CONNECT_ONESHOT)
var matches = _keytext_regex.search(text)
if not matches:
escoria.logger.error(
self,
"Unexpected text encountered : %s." % text
)
var key = matches.get_string("key")
if matches.get_string("key") != "":
var _speech_resource = _get_voice_file(
matches.get_string("key")
)
if _speech_resource == "":
escoria.logger.warn(
self,
"Unable to find voice file with key '%s'." % matches.get_string("key")
)
else:
(
escoria.object_manager.get_object(escoria.object_manager.SPEECH).node\
as ESCSpeechPlayer
).set_state(_speech_resource)
var translated_text: String = tr(matches.get_string("key"))
# Only update the text if the translated text was found; otherwise, raise
# a warning and use the original, untranslated text.
if translated_text == matches.get_string("key"):
escoria.logger.warn(
self,
"Unable to find translation key '%s'. Using untranslated text." % matches.get_string("key")
)
text = matches.get_string("text")
else:
text = translated_text
else:
text = matches.get_string("text")
states_map["say"].initialize(character, type, text)
_change_state("say")
_dialog_manager.say(self, character, text, type)
# Handles the end of a say function after it has emitted say_finished.
func _on_say_finished():
is_speaking = false
emit_signal("say_finished")
# Called when a dialog line is skipped
# Called when a dialog line is to be sped up.
func speedup() -> void:
if is_speaking and escoria.inputs_manager.input_mode != \
escoria.inputs_manager.INPUT_NONE and \
_dialog_manager != null:
_dialog_manager.speedup()
_change_state("say_fast")
# Display a list of choices
@@ -180,34 +81,20 @@ func speedup() -> void:
#
# - dialog: The dialog to start
func start_dialog_choices(dialog: ESCDialog, type: String = "simple"):
if dialog.options.empty():
escoria.logger.error(
self,
"Received dialog options array was empty."
)
var _dialog_chooser_ui: ESCDialogManager = null
for _manager_class in ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
var _manager: ESCDialogManager = load(_manager_class).new()
if _manager.has_chooser_type(type):
_dialog_chooser_ui = _manager
if _dialog_chooser_ui == null:
escoria.logger.error(
self,
"No dialog manager supports the chooser type %s." % type
)
_dialog_chooser_ui.choose(self, dialog)
var option = yield(_dialog_chooser_ui, "option_chosen")
emit_signal("option_chosen", option)
states_map["choices"].initialize(self, dialog, type)
_change_state("choices")
# Interrupt the currently running dialog
func interrupt():
if _dialog_manager != null:
_dialog_manager.interrupt()
func interrupt() -> void:
_change_state("interrupt")
# Since the dialog manager is determined when a `say` command is performed and
# other states need to know which one was picked, we notify the necessary states
# via this method.
func _on_dialog_manager_set(dialog_manager: ESCDialogManager) -> void:
_dialog_manager = dialog_manager
states_map["say_fast"].initialize(dialog_manager)
states_map["visible"].initialize(dialog_manager)
states_map["interrupt"].initialize(dialog_manager)

View File

@@ -0,0 +1,58 @@
extends State
class_name DialogChoices
# The owning dialog player.
var _dialog_player
# The dialog to start.
var _dialog: ESCDialog
var _type: String = "simple"
var _dialog_chooser_ui: ESCDialogManager = null
var _ready_to_choose: bool
func initialize(dialog_player, dialog: ESCDialog, type: String) -> void:
_dialog_player = dialog_player
_dialog = dialog
_type = type
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'choices'.")
if _dialog.options.empty():
escoria.logger.error(
self,
"Received dialog options array was empty."
)
for _manager_class in ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
var _manager: ESCDialogManager = load(_manager_class).new()
if _manager.has_chooser_type(_type):
_dialog_chooser_ui = _manager
if _dialog_chooser_ui == null:
escoria.logger.error(
self,
"No dialog manager supports the chooser type %s." % _type
)
_ready_to_choose = true
func update(_delta):
if _ready_to_choose:
_ready_to_choose = false
_dialog_chooser_ui.choose(self, _dialog)
var option = yield(_dialog_chooser_ui, "option_chosen")
escoria.logger.trace(self, "Dialog State Machine: 'choices' -> 'idle'")
emit_signal("finished", "idle")
_dialog_player.emit_signal("option_chosen", option)

View File

@@ -0,0 +1,20 @@
extends State
class_name DialogFinish
# Owning dialog player
var _dialog_player
func initialize(dialog_player) -> void:
_dialog_player = dialog_player
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'finish'.")
func update(_delta):
escoria.logger.trace(self, "Dialog State Machine: 'finish' -> 'idle'")
emit_signal("finished", "idle")
_dialog_player.emit_signal("say_finished")

View File

@@ -0,0 +1,6 @@
extends State
class_name DialogIdle
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'idle'.")

View File

@@ -0,0 +1,26 @@
extends State
class_name DialogInterrupt
# Reference to the currently playing dialog manager
var _dialog_manager: ESCDialogManager = null
func initialize(dialog_manager: ESCDialogManager) -> void:
_dialog_manager = dialog_manager
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'interrupt'.")
if not _dialog_manager.is_connected("say_finished", self, "_on_say_finished"):
_dialog_manager.connect("say_finished", self, "_on_say_finished", [], CONNECT_ONESHOT)
if _dialog_manager != null:
_dialog_manager.interrupt()
func _on_say_finished() -> void:
escoria.logger.trace(self, "Dialog State Machine: 'interrupt' -> 'finish'")
emit_signal("finished", "finish")

View File

@@ -0,0 +1,177 @@
extends State
class_name DialogSay
signal dialog_manager_set(dialog_manager)
# A regular expression that separates the translation key from the text
const KEYTEXT_REGEX = "^((?<key>[^:]+):)?\"(?<text>.+)\""
# Reference to the currently playing dialog manager
var _dialog_manager: ESCDialogManager = null
# Character that is talking
var _character: String
# UI to use for the dialog
var _type: String
# Text to say
var _text: String
# Regular expression object for the separation of key and text
var _keytext_regex: RegEx = RegEx.new()
var _ready_to_say: bool
# Constructor
func _init() -> void:
_keytext_regex.compile(KEYTEXT_REGEX)
func initialize(character: String, type: String, text: String) -> void:
_character = character
_type = type
_text = text
func handle_input(_event):
if _event is InputEventMouseButton and _event.pressed:
if escoria.inputs_manager.input_mode != \
escoria.inputs_manager.INPUT_NONE and \
_dialog_manager != null:
if _dialog_manager.is_connected("say_visible", self, "_on_say_visible"):
_dialog_manager.disconnect("say_visible", self, "_on_say_visible")
escoria.logger.trace(self, "Dialog State Machine: 'say' -> 'say_fast'")
emit_signal("finished", "say_fast")
get_tree().set_input_as_handled()
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'say'.")
if _type == "":
_type = ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DEFAULT_DIALOG_TYPE
)
var dialog_manager: ESCDialogManager = null
for _manager_class in ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
var _manager: ESCDialogManager = load(_manager_class).new()
if _manager.has_type(_type):
dialog_manager = _manager
else:
dialog_manager = null
if dialog_manager == null:
escoria.logger.error(
self,
"No dialog manager called '%s' configured." % _type
)
_dialog_manager = dialog_manager
emit_signal("dialog_manager_set", dialog_manager)
if not _dialog_manager.is_connected("say_visible", self, "_on_say_visible"):
_dialog_manager.connect("say_visible", self, "_on_say_visible", [], CONNECT_ONESHOT)
var matches = _keytext_regex.search(_text)
if not matches:
escoria.logger.error(
self,
"Unexpected text encountered: %s." % _text
)
var key = matches.get_string("key")
if matches.get_string("key") != "":
var _speech_resource = _get_voice_file(
matches.get_string("key")
)
if _speech_resource == "":
escoria.logger.warn(
self,
"Unable to find voice file with key '%s'." % matches.get_string("key")
)
else:
(
escoria.object_manager.get_object(escoria.object_manager.SPEECH).node\
as ESCSpeechPlayer
).set_state(_speech_resource)
var translated_text: String = tr(matches.get_string("key"))
# Only update the text if the translated text was found; otherwise, raise
# a warning and use the original, untranslated text.
if translated_text == matches.get_string("key"):
escoria.logger.warn(
self,
"Unable to find translation key '%s'. Using untranslated text." % matches.get_string("key")
)
_text = matches.get_string("text")
else:
_text = translated_text
else:
_text = matches.get_string("text")
_ready_to_say = true
func update(_delta):
if _ready_to_say:
_dialog_manager.say(self, _character, _text, _type)
_ready_to_say = false
# Find the matching voice output file for the given key
#
# #### Parameters
#
# - key: Text key provided
# - start: Starting folder to search for voices
#
# *Returns* The path to the matching voice file
func _get_voice_file(key: String, start: String = "") -> String:
if start == "":
start = ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.SPEECH_FOLDER
)
var _dir = Directory.new()
if _dir.open(start) == OK:
_dir.list_dir_begin(true, true)
var file_name = _dir.get_next()
while file_name != "":
if _dir.current_is_dir():
var _voice_file = _get_voice_file(
key,
start.plus_file(file_name)
)
if _voice_file != "":
return _voice_file
else:
if file_name == "%s.%s.import" % [
key,
ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.SPEECH_EXTENSION
)
]:
return start.plus_file(file_name.trim_suffix(".import"))
file_name = _dir.get_next()
return ""
func _on_say_visible() -> void:
escoria.logger.trace(self, "Dialog State Machine: 'say' -> 'visible'")
emit_signal("finished", "visible")

View File

@@ -0,0 +1,30 @@
extends State
class_name DialogSayFast
# Reference to the currently playing dialog manager
var _dialog_manager: ESCDialogManager = null
func initialize(dialog_manager: ESCDialogManager) -> void:
_dialog_manager = dialog_manager
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'say_fast'.")
if escoria.inputs_manager.input_mode != \
escoria.inputs_manager.INPUT_NONE and \
_dialog_manager != null:
if not _dialog_manager.is_connected("say_visible", self, "_on_say_visible"):
_dialog_manager.connect("say_visible", self, "_on_say_visible", [], CONNECT_ONESHOT)
_dialog_manager.speedup()
else:
escoria.logger.error(self, "Illegal state.")
func _on_say_visible() -> void:
escoria.logger.trace(self, "Dialog State Machine: 'say_fast' -> 'visible'")
emit_signal("finished", "visible")

View File

@@ -0,0 +1,35 @@
extends State
class_name DialogVisible
# Reference to the currently playing dialog manager
var _dialog_manager: ESCDialogManager = null
func initialize(dialog_manager: ESCDialogManager) -> void:
_dialog_manager = dialog_manager
func enter():
escoria.logger.trace(self, "Dialog State Machine: Entered 'visible'.")
if not _dialog_manager.is_connected("say_finished", self, "_on_say_finished"):
_dialog_manager.connect("say_finished", self, "_on_say_finished", [], CONNECT_ONESHOT)
func handle_input(_event):
if _event is InputEventMouseButton and _event.pressed:
if escoria.inputs_manager.input_mode != \
escoria.inputs_manager.INPUT_NONE:
if _dialog_manager.is_connected("say_finished", self, "_on_say_finished"):
_dialog_manager.disconnect("say_finished", self, "_on_say_finished")
emit_signal("finished", "interrupt")
get_tree().set_input_as_handled()
# Handles the end of a say function after it has emitted say_finished.
func _on_say_finished():
escoria.logger.trace(self, "Dialog State Machine: 'visible' -> 'finish'")
emit_signal("finished", "finish")

View File

@@ -0,0 +1,32 @@
"""
Base interface for all states: it doesn't do anything in itself
but forces us to pass the right arguments to the methods below
and makes sure every State object had all of these methods.
"""
extends Node
class_name State
signal finished(next_state_name)
# Initialize the state. E.g. change the animation
func enter():
return
# Clean up the state. Reinitialize values like a timer
func exit():
return
func handle_input(_event):
return
func update(_delta):
return
func _on_animation_finished(_anim_name):
return

View File

@@ -0,0 +1,93 @@
"""
Base interface for a generic state machine
It handles initializing, setting the machine active or not
delegating _physics_process, _input calls to the State nodes,
and changing the current/active state.
"""
extends Node
class_name StateMachine
signal state_changed(current_state)
"""
You must set a starting node from the inspector or on
the node that inherits from this state machine interface
If you don't the game will crash (on purpose, so you won't
forget to initialize the state machine)
"""
export(NodePath) var START_STATE
var states_map = {}
var states_stack = [] # can also be used as a pushdown automaton
var current_state = null
var current_state_name = ""
var _active = false setget set_active
func initialize(start_state):
for child in get_children():
child.connect("finished", self, "_change_state")
set_active(true)
states_stack.push_front(start_state)
current_state = states_stack[0]
current_state.enter()
func set_active(value):
_active = value
set_physics_process(value)
set_process_input(value)
if not _active:
states_stack = []
current_state = null
func _input(event):
current_state.handle_input(event)
func _physics_process(delta):
current_state.update(delta)
func _on_animation_finished(anim_name):
if not _active:
return
current_state._on_animation_finished(anim_name)
func _change_state(state_name):
if not _active:
return
escoria.logger.trace(
self,
"Dialog State Machine: Changing state from '%s' to '%s'." % [current_state_name, state_name]
)
current_state.exit()
if state_name == "previous":
states_stack.pop_front()
else:
states_stack[0] = states_map[state_name]
current_state = states_stack[0]
emit_signal("state_changed", current_state)
#if state_name != "previous":
current_state.enter()
current_state_name = state_name
func get_current_state_name():
for key in states_map.keys():
if states_map[key] == current_state:
return key
return null