# ############################################################################ # # Copyright © 2018-2022 Paul Joannon # Copyright © 2019-2022 Frédéric Maquin # 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)