Figments-of-the-Night/addons/inkgd/ink_player.gd
Gerard Gascón b99855351d init
2025-04-24 17:23:34 +02:00

886 lines
26 KiB
GDScript

# ############################################################################ #
# Copyright © 2018-2022 Paul Joannon
# Copyright © 2019-2022 Frédéric Maquin <fred@ephread.com>
# Licensed under the MIT License.
# See LICENSE in the project root for license information.
# ############################################################################ #
extends Node
class_name InkPlayer
# ############################################################################ #
# Imports
# ############################################################################ #
var InkRuntime = load("res://addons/inkgd/runtime.gd")
var InkResource = load("res://addons/inkgd/editor/import_plugins/ink_resource.gd")
var InkStory = load("res://addons/inkgd/runtime/story.gd")
var InkFunctionResult = load("res://addons/inkgd/runtime/extra/function_result.gd")
# ############################################################################ #
# Signals
# ############################################################################ #
## Emitted when the ink runtime encountered an exception. Exception are
## usually not recoverable as they corrupt the state. `stack_trace` is
## an optional PoolStringArray containing a stack trace, for logging purposes.
signal exception_raised(message, stack_trace)
## Emitted when the _story encountered an error. These errors are usually
## recoverable.
signal error_encountered(message, type)
## Emitted with `true` when the runtime had loaded the JSON file and created
## the _story. If an error was encountered, `successfully` will be `false` and
## and error will appear Godot's output.
signal loaded(successfully)
## Emitted with the text and tags of the current line when the _story
## successfully continued.
signal continued(text, tags)
## Emitted when using `continue_async`, if the time spent evaluating the ink
## exceeded the alloted time.
signal interrupted()
## Emitted when the player should pick a choice.
signal prompt_choices(choices)
## Emitted when a choice was reported back to the runtime.
signal choice_made(choice)
## Emitted when an external function is about to evaluate.
signal function_evaluating(function_name, arguments)
## Emitted when an external function evaluated.
signal function_evaluated(function_name, arguments, function_result)
## Emitted when a valid path string was choosen.
signal path_choosen(path, arguments)
## Emitted when the _story ended.
signal ended()
# ############################################################################ #
# Exported Properties
# ############################################################################ #
## The compiled Ink file (.json) to play.
@export var ink_file: Resource
## When `true` the _story will be created in a separate threads, to
## prevent the UI from freezing if the _story is too big. Note that
## on platforms where threads aren't available, the value of this
## property is ignored.
@export var loads_in_background: bool = true
# ############################################################################ #
# Properties
# ############################################################################ #
# These properties aren't exported because they depend on the runtime or the
# story to be set. The runtime insn't always available upon instantiation,
# and the story is only available after calling 'create_story' so rather than
# losing the values and confusing everybody, those properties are code-only.
## `true` to allow external function fallbacks, `false` otherwise. If this
## property is `false` and the appropriate function hasn't been binded, the
## _story will output an error.
var allow_external_function_fallbacks: bool: get = get_aeff, set = set_aeff
func set_aeff(value: bool):
if _story == null:
_push_null_story_error()
return
_story.allow_external_function_fallbacks = value
func get_aeff() -> bool:
if _story == null:
_push_null_story_error()
return false
return _story.allow_external_function_fallbacks
# skips saving global values that remain equal to the initial values that were
# declared in Ink.
var do_not_save_default_values: bool: get = get_dnsdv, set = set_dnsdv
func set_dnsdv(value: bool):
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return false
ink_runtime.dont_save_default_values = value
func get_dnsdv() -> bool:
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return false
return ink_runtime.dont_save_default_values
## Uses `assert` instead of `push_error` to report critical errors, thus
## making them more explicit during development.
var stop_execution_on_exception: bool: get = get_seoex, set = set_seoex
func set_seoex(value: bool):
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return
ink_runtime.stop_execution_on_exception = value
func get_seoex() -> bool:
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return false
return ink_runtime.stop_execution_on_exception
## Uses `assert` instead of `push_error` to report _story errors, thus
## making them more explicit during development.
var stop_execution_on_error: bool: get = get_seoer, set = set_seoer
func set_seoer(value: bool):
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return
ink_runtime.stop_execution_on_error = value
func get_seoer() -> bool:
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
return false
return ink_runtime.stop_execution_on_error
# ############################################################################ #
# Read-only Properties
# ############################################################################ #
## `true` if the _story can continue (i. e. is not expecting a choice to be
## choosen and hasn't reached the end).
var can_continue: bool: get = get_can_continue
func get_can_continue() -> bool:
if _story == null:
_push_null_story_error()
return false
return _story.can_continue
## If `continue_async` was called (with milliseconds limit > 0) then this
## property will return false if the ink evaluation isn't yet finished, and
## you need to call it again in order for the continue to fully complete.
var async_continue_complete: bool: get = get_async_continue_complete
func get_async_continue_complete() -> bool:
if _story == null:
_push_null_story_error()
return false
return _story.async_continue_complete
## The content of the current line.
var current_text: String: get = get_current_text
func get_current_text() -> String:
if _story == null:
_push_null_story_error()
return ""
return _story.current_text
## The current choices. Empty is there are no choices for the current line.
var current_choices: Array: get = get_current_choices
func get_current_choices() -> Array:
if _story == null:
_push_null_story_error()
return []
return _story.current_choices.duplicate()
## The current tags. Empty is there are no tags for the current line.
var current_tags: Array: get = get_current_tags
func get_current_tags() -> Array:
if _story == null:
_push_null_story_error()
return []
if _story.current_tags == null:
return []
return _story.current_tags
## The global tags for the _story. Empty if none have been declared.
var global_tags: Array: get = get_global_tags
func get_global_tags() -> Array:
if _story == null:
_push_null_story_error()
return []
if _story.global_tags == null:
return []
return _story.global_tags
## `true` if the _story currently has choices, `false` otherwise.
var has_choices: bool: get = get_has_choices
func get_has_choices() -> bool:
return !self.current_choices.is_empty()
## The name of the current flow.
var current_flow_name: String: get = get_current_flow_name
func get_current_flow_name() -> String:
if _story == null:
_push_null_story_error()
return ""
return _story.state.current_flow_name
## The names of all flows currently alive.
var alive_flow_names: Array: get = get_alive_flow_names
func get_alive_flow_names() -> Array:
if _story == null:
_push_null_story_error()
return []
return _story.alive_flow_names
## `true` if the current flow is the default flow.
var current_flow_is_default_flow: bool: get = get_current_flow_is_default_flow
func get_current_flow_is_default_flow() -> bool:
if _story == null:
_push_null_story_error()
return false
return _story.current_flow_is_default_flow
## The current story path.
var current_path: String: get = get_current_path
func get_current_path() -> String:
if _story == null:
_push_null_story_error()
return ""
var path = _story.state.current_path_string
if path == null:
return ""
else:
return path
# ############################################################################ #
# Private Properties
# ############################################################################ #
var _ink_runtime: WeakRef = WeakRef.new()
var _story: InkStory = null
var _thread: Thread
var _manages_runtime: bool = false
# ############################################################################ #
# Initialization
# ############################################################################ #
func _init():
name = "InkPlayer"
# ############################################################################ #
# Overrides
# ############################################################################ #
func _exit_tree():
call_deferred("_remove_runtime")
# ############################################################################ #
# Methods
# ############################################################################ #
## Creates the _story, based on the value of `ink_file`. The result of this
## method is reported through the 'story_loaded' signal.
func create_story() -> int:
if ink_file == null:
_push_error("'ink_file' is null, did Godot import the resource correctly?")
call_deferred("emit_signal", "loaded", false)
return ERR_CANT_CREATE
if !("json" in ink_file) || typeof(ink_file.json) != TYPE_STRING:
_push_error(
"'ink_file' doesn't have the appropriate resource type." + \
"Are you sure you imported a JSON file?"
)
call_deferred("emit_signal", "loaded", false)
return ERR_CANT_CREATE
if loads_in_background && _current_platform_supports_threads():
_thread = Thread.new()
var error = _thread.start(_async_create_story.bind(ink_file.json, _ink_runtime.get_ref()))
if error != OK:
printerr("[inkgd] [ERROR] Could not start the thread: error code %d", error)
call_deferred("emit_signal", "loaded", false)
return error
else:
return OK
else:
call_deferred("_create_and_finalize_story", ink_file.json, _ink_runtime.get_ref())
return OK
## Reset the Story back to its initial state as it was when it was
## first constructed.
func reset() -> void:
if _story == null:
_push_null_story_error()
return
_story.reset_state()
## Destroys the current story.
func destroy() -> void:
_story = null
# ############################################################################ #
# Methods | Story Flow
# ############################################################################ #
## Continues the story.
func continue_story() -> String:
if _story == null:
_push_null_story_error()
return ""
var text: String = ""
if self.can_continue:
_story.continue_story()
text = self.current_text
elif self.has_choices:
emit_signal("prompt_choices", self.current_choices)
else:
emit_signal("ended")
return text
## An "asynchronous" version of `continue_story` that only partially evaluates
## the ink, with a budget of a certain time limit. It will exit ink evaluation
## early if the evaluation isn't complete within the time limit, with the
## `async_continue_complete` property being false. This is useful if the
## evaluation takes a long time, and you want to distribute it over multiple
## game frames for smoother animation. If you pass a limit of zero, then it will
## fully evaluate the ink in the same way as calling continue_story.
##
## To get notified when the evaluation is exited early, you can connect to the
## `interrupted` signal.
func continue_story_async(millisecs_limit_async: float) -> void:
if _story == null:
_push_null_story_error()
return
if self.can_continue:
_story.continue_async(millisecs_limit_async)
if !self.async_continue_complete:
emit_signal("interrupted")
return
elif self.has_choices:
emit_signal("prompt_choices", self.current_choices)
else:
emit_signal("ended")
## Continue the story until the next choice point or until it runs out of
## content. This is as opposed to `continue` which only evaluates one line
## of output at a time. It returns the resulting text evaluated by the ink
## engine, concatenated together.
func continue_story_maximally() -> String:
if _story == null:
_push_null_story_error()
return ""
var text: String = ""
if self.can_continue:
_story.continue_story_maximally()
text = self.current_text
elif self.has_choices:
emit_signal("prompt_choices", self.current_choices)
else:
emit_signal("ended")
return text
## Chooses a choice. If the _story is not currently expected choices or
## the index is out of bounds, this method does nothing.
func choose_choice_index(index: int) -> void:
if _story == null:
_push_null_story_error()
return
if index >= 0 && index < self.current_choices.size():
_story.choose_choice_index(index);
## Moves the _story to the specified knot/stitch/gather. This method
## will throw an error through the 'exception' signal if the path string
## does not match any known path.
func choose_path(path: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.choose_path_string(path)
## Switches the flow, creating a new flow if it doesn't exist.
func switch_flow(flow_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.switch_flow(flow_name)
## Switches the the default flow.
func switch_to_default_flow() -> void:
if _story == null:
_push_null_story_error()
return
_story.switch_to_default_flow()
## Remove the given flow.
func remove_flow(flow_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.remove_flow(flow_name)
# ############################################################################ #
# Methods | Tags
# ############################################################################ #
## Returns the tags declared at the given path.
func tags_for_content_at_path(path: String) -> Array:
if _story == null:
_push_null_story_error()
return []
return _story.tags_for_content_at_path(path)
# ############################################################################ #
# Methods | Visit Count
# ############################################################################ #
## Returns the visit count of the given path.
func visit_count_at_path(path: String) -> int:
if _story == null:
_push_null_story_error()
return 0
return _story.state.visit_count_at_path_string(path)
# ############################################################################ #
# Methods | State Management
# ############################################################################ #
## Gets the current state as a JSON string. It can then be saved somewhere.
func get_state() -> String:
if _story == null:
_push_null_story_error()
return ""
return _story.state.to_json()
## If you have a large story, and saving state to JSON takes too long for your
## framerate, you can temporarily freeze a copy of the state for saving on
## a separate thread. Internally, the engine maintains a "diff patch".
## When you've finished saving your state, call `background_save_complete`
## and that diff patch will be applied, allowing the story to continue
## in its usual mode.
func copy_state_for_background_thread_save() -> String:
if _story == null:
_push_null_story_error()
return ""
return _story.copy_state_for_background_thread_save().to_json()
## See `copy_state_for_background_thread_save`. This method releases the
## "frozen" save state, applying its patch that it was using internally.
func background_save_complete() -> void:
if _story == null:
_push_null_story_error()
return
_story.background_save_complete()
## Sets the state from a JSON string.
func set_state(state: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.state.load_json(state)
## Saves the current state to the given path.
func save_state_to_path(path: String):
if _story == null:
_push_null_story_error()
return
if !path.begins_with("res://") && !path.begins_with("user://"):
path = "user://%s" % path
var file := FileAccess.open(path, FileAccess.WRITE)
save_state_to_file(file)
file.close()
## Saves the current state to the file.
func save_state_to_file(file: FileAccess):
if _story == null:
_push_null_story_error()
return
if file.is_open():
file.store_string(get_state())
# TODO: Add save and load in background
## Loads the state from the given path.
func load_state_from_path(path: String):
if _story == null:
_push_null_story_error()
return
if !path.begins_with("res://") && !path.begins_with("user://"):
path = "user://%s" % path
var file := FileAccess.open(path, FileAccess.READ)
load_state_from_file(file)
file.close()
## Loads the state from the given file.
func load_state_from_file(file: FileAccess):
if _story == null:
_push_null_story_error()
return
if !file.is_open():
return
file.seek(0);
if file.get_length() > 0:
_story.state.load_json(file.get_as_text())
# ############################################################################ #
# Methods | Variables
# ############################################################################ #
## Returns the value of variable named 'name' or 'null' if it doesn't exist.
@warning_ignore("shadowed_variable_base_class")
func get_variable(name: String):
if _story == null:
_push_null_story_error()
return null
return _story.variables_state.get_variable(name)
## Sets the value of variable named 'name'.
@warning_ignore("shadowed_variable_base_class")
func set_variable(name: String, value):
if _story == null:
_push_null_story_error()
return
_story.variables_state.set_variable(name, value)
# ############################################################################ #
# Methods | Variable Observers
# ############################################################################ #
## Registers an observer for the given variables.
func observe_variables(variable_names: Array, object: Object, method_name: String):
if _story == null:
_push_null_story_error()
return
_story.observe_variables(variable_names, object, method_name)
## Registers an observer for the given variable.
func observe_variable(variable_name: String, object: Object, method_name: String):
if _story == null:
_push_null_story_error()
return
_story.observe_variable(variable_name, object, method_name)
## Removes an observer for the given variable name. This method is highly
## specific and will only remove one observer.
func remove_variable_observer(object: Object, method_name: String, specific_variable_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.remove_variable_observer(object, method_name, specific_variable_name)
## Removes all observers registered with the couple object/method_name,
## regardless of which variable they observed.
func remove_variable_observer_for_all_variables(object: Object, method_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.remove_variable_observer(object, method_name)
## Removes all observers observing the given variable.
func remove_all_variable_observers(specific_variable_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.remove_variable_observer(null, null, specific_variable_name)
# ############################################################################ #
# Methods | External Functions
# ############################################################################ #
## Binds an external function.
func bind_external_function(
func_name: String,
object: Object,
method_name: String,
lookahead_safe = false
) -> void:
if _story == null:
_push_null_story_error()
return
_story.bind_external_function(func_name, object, method_name, lookahead_safe)
## Unbinds an external function.
func unbind_external_function(func_name: String) -> void:
if _story == null:
_push_null_story_error()
return
_story.unbind_external_function(func_name)
# ############################################################################ #
# Methods | Functions
# ############################################################################ #
func has_function(function_name: String) -> bool:
return _story.has_function(function_name)
## Evaluate a given ink function, returning its return value (but not
## its output).
func evaluate_function(function_name: String, arguments = []) -> InkFunctionResult:
if _story == null:
_push_null_story_error()
return null
var result = _story.evaluate_function(function_name, arguments, true)
if result != null:
return InkFunctionResult.new(result["output"], result["result"])
else:
return null
# ############################################################################ #
# Methods | Ink List Creation
# ############################################################################ #
## Creates a new empty InkList that's intended to hold items from a particular
## origin list definition.
func create_ink_list_with_origin(single_origin_list_name: String) -> InkList:
return InkList.new_with_origin(single_origin_list_name, _story)
## Creates a new InkList from the name of a preexisting item.
func create_ink_list_from_item_name(item_name: String) -> InkList:
return InkList.from_string(item_name, _story)
# ############################################################################ #
# Private Methods | Signal Forwarding
# ############################################################################ #
func _exception_raised(message, stack_trace) -> void:
emit_signal("exception_raised", message, stack_trace)
func _on_error(message, type) -> void:
if get_signal_connection_list("error_encountered").size() == 0:
_push_story_error(message, type)
else:
emit_signal("error_encountered", message, type)
func _on_did_continue() -> void:
emit_signal("continued", self.current_text, self.current_tags)
func _on_make_choice(choice) -> void:
emit_signal("choice_made", choice.text)
func _on_evaluate_function(function_name, arguments) -> void:
emit_signal("function_evaluating", function_name, arguments)
func _on_complete_evaluate_function(function_name, arguments, text_output, return_value) -> void:
var function_result = InkFunctionResult.new(text_output, return_value)
emit_signal("function_evaluated", function_name, arguments, function_result)
func _on_choose_path_string(path, arguments) -> void:
emit_signal("path_choosen", path, arguments)
# ############################################################################ #
# Private Methods
# ############################################################################ #
func _create_story(json_story, runtime) -> void:
_story = InkStory.new(json_story, runtime)
func _async_create_story(json_story, runtime) -> void:
_create_story(json_story, runtime)
call_deferred("_async_creation_completed")
func _async_creation_completed() -> void:
_thread.wait_to_finish()
_thread = null
_finalise_story_creation()
func _create_and_finalize_story(json_story, runtime) -> void:
_create_story(json_story, runtime)
_finalise_story_creation()
func _finalise_story_creation() -> void:
_story.connect("on_error", Callable(self, "_on_error"))
_story.connect("on_did_continue", Callable(self, "_on_did_continue"))
_story.connect("on_make_choice", Callable(self, "_on_make_choice"))
_story.connect("on_evaluate_function", Callable(self, "_on_evaluate_function"))
_story.connect("on_complete_evaluate_function", Callable(self, "_on_complete_evaluate_function"))
_story.connect("on_choose_path_string", Callable(self, "_on_choose_path_string"))
var ink_runtime = _ink_runtime.get_ref()
if ink_runtime == null:
_push_null_runtime_error()
emit_signal("loaded", false)
return
emit_signal("loaded", true)
func _add_runtime(root) -> void:
# The InkRuntime is normaly an auto-loaded singleton,
# but if it's not present, it's added here.
var runtime: Node
if root.has_node("__InkRuntime"):
runtime = root.get_node("__InkRuntime")
else:
_manages_runtime = true
runtime = InkRuntime.init(root)
if !runtime.is_connected("exception_raised", _exception_raised):
runtime.connect("exception_raised", _exception_raised)
_ink_runtime = weakref(runtime)
func _remove_runtime() -> void:
if _manages_runtime:
InkRuntime.deinit(get_tree().root)
func _current_platform_supports_threads() -> bool:
return OS.get_name() != "HTML5"
func _push_null_runtime_error() -> void:
_push_error(
"InkRuntime could not found, did you remove it from the tree?"
)
func _push_null_story_error() -> void:
_push_error("The _story is 'null', was it loaded properly?")
func _push_story_error(message: String, type: int) -> void:
if Engine.is_editor_hint():
match type:
Ink.ErrorType.ERROR:
printerr(message)
Ink.ErrorType.WARNING, Ink.ErrorType.AUTHOR:
print(message)
else:
match type:
Ink.ErrorType.ERROR:
push_error(message)
Ink.ErrorType.WARNING, Ink.ErrorType.AUTHOR:
push_warning(message)
func _push_error(message: String):
if Engine.is_editor_hint():
printerr(message)
var i = 1
for stack_element in get_stack():
if i <= 2:
i += 1
continue
printerr(
" ", (i - 2), " - ", stack_element["source"], ":",
stack_element["line"], " - at function: ", stack_element["function"]
)
else:
push_error(message)