// /////////////////////////////////////////////////////////////////////////// / // 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. // /////////////////////////////////////////////////////////////////////////// / using Godot; using System; using System.Linq; using System.Collections.Generic; using System.Diagnostics; [Tool] public partial class InkPlayer : Node { #region Signals [Signal] public delegate void exception_raised(string message, Godot.Collections.Array stack_trace); [Signal] public delegate void error_encountered(string message, int type); [Signal] public delegate void loaded(bool successfully); [Signal] public delegate void continued(string text, Godot.Collections.Array tags); [Signal] public delegate void interrupted(); [Signal] public delegate void prompt_choices(Godot.Collections.Array choices); [Signal] public delegate void choice_made(Godot.Object choice); [Signal] public delegate void function_evaluating(Godot.Collections.Array function_name, object[] arguments); [Signal] public delegate void function_evaluated(Godot.Collections.Array function_name, object[] arguments, Godot.Object function_result); [Signal] public delegate void path_choosen(string path, object[] arguments); [Signal] public delegate void ended(); #endregion #region Exported properties [Export] public Godot.Resource ink_file = null; [Export] public bool loads_in_background = false; #endregion #region 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. public bool allow_external_function_fallbacks { get { if (story == null) { PushNullStoryError(); return false; } return story.allowExternalFunctionFallbacks; } set { if (story == null) { PushNullStoryError(); return; } story.allowExternalFunctionFallbacks = value; } } public bool do_not_save_default_values { get { return Ink.Runtime.VariablesState.dontSaveDefaultValues; } set { Ink.Runtime.VariablesState.dontSaveDefaultValues = value; } } public bool stop_execution_on_exception = false; public bool stop_execution_on_error = false; #endregion #region Read-only Properties public bool can_continue { get { if (story == null) { PushNullStoryError(); return false; } return story.canContinue; } } public bool async_continue_complete { get { if (story == null) { PushNullStoryError(); return false; } return story.asyncContinueComplete; } } public string current_text { get { if (story == null) { PushNullStoryError(); return ""; } if (story.currentText == null) { PushNullStateError("current_choices"); return ""; } return story.currentText ?? ""; } } public Godot.Collections.Array current_choices { get { if (story == null) { PushNullStoryError(); return new Godot.Collections.Array(); } if (story.currentChoices == null) { PushNullStateError("current_choices"); return new Godot.Collections.Array(); } if (choices != null) { return new Godot.Collections.Array(story.currentChoices); } else { return new Godot.Collections.Array(); } } } public Godot.Collections.Array current_tags { get { if (story == null) { PushNullStoryError(); return new Godot.Collections.Array(); } if (story.currentTags == null) { PushNullStateError("current_tags"); return new Godot.Collections.Array(); } if (story.currentTags != null) { return new Godot.Collections.Array(story.currentTags); } else { return new Godot.Collections.Array(); } } } public Godot.Collections.Array global_tags { get { if (story == null) { PushNullStoryError(); return new Godot.Collections.Array(); } if (story.globalTags != null) { return new Godot.Collections.Array(story.globalTags); } else { return new Godot.Collections.Array(); } } } public bool has_choices { get { return current_choices.Count > 0; } } public string current_flow_name { get { if (story == null) { PushNullStoryError(); return ""; } return story.state.currentFlowName; } } public string current_path { get { if (story == null) { PushNullStoryError(); return ""; } return story.state.currentPathString; } } #endregion #region Private Properties private Ink.Runtime.Story story = null; private Thread thread = null; private readonly InkBridger inkBridger = new InkBridger(); private Dictionary> observers = new Dictionary>(); #endregion #region Private Properties public InkPlayer() { this.Set("name", "InkPlayer"); } #endregion #region Methods public void create_story() { if (ink_file == null) { PushError("'ink_file' is 'Nil', did Godot import the resource correctly?"); CallDeferred("emit_signal", "loaded", false); return; } if (!IsValidResource(ink_file)) { PushError( "'ink_file' doesn't have the appropriate resource type. Are you sure you imported a JSON file?" ); CallDeferred("emit_signal", "loaded", false); return; } if (loads_in_background && CurrentPlatformSupportsThreads()) { thread = new Thread(); var error = thread.Start(this, "AsyncCreateStory", (string)ink_file.Get("json")); if (error != Error.Ok) { GD.PrintErr($"[inkgd] [ERROR] Could not start the thread: error code {error}."); EmitSignal("loaded", false); } } else { CreateStory((string)ink_file.Get("json")); FinaliseStoryCreation(); } } public void reset() { if (story == null) { PushNullStoryError(); return; } try { story.ResetState(); } catch (Exception e) { HandleException(e); } } public void destroy() { story = null; } #endregion #region Methods | Story Flow public string continue_story() { if (story == null) { PushNullStoryError(); return ""; } var text = ""; try { if (can_continue) { story.Continue(); text = current_text; } else if (has_choices) { EmitSignal("prompt_choices", new object[] { current_choices }); } else { EmitSignal("ended"); } return text; } catch (Exception e) { HandleException(e); return text; } } public void continue_story_async(float millisecs_limit_async) { if (story == null) { PushNullStoryError(); return; } try { if (can_continue) { story.ContinueAsync(millisecs_limit_async); if (!async_continue_complete) { EmitSignal("interrupted"); return; } } else if (has_choices) { EmitSignal("prompt_choices", new object[] { current_choices }); } else { EmitSignal("ended"); } return; } catch (Exception e) { HandleException(e); return; } } public string continue_story_maximally() { if (story == null) { PushNullStoryError(); return ""; } var text = ""; try { if (can_continue) { story.ContinueMaximally(); text = current_text; } else if (has_choices) { EmitSignal("prompt_choices", new object[] { current_choices }); } else { EmitSignal("ended"); } return text; } catch (Exception e) { HandleException(e); return text; } } public void choose_choice_index(int index) { try { if (index >= 0 && index < current_choices.Count) { story.ChooseChoiceIndex(index); } } catch (Exception e) { HandleException(e); } } public void choose_path(string path) { if (story == null) { PushNullStoryError(); return; } try { story.ChoosePathString(path); } catch (Exception e) { HandleException(e); } } public void switch_flow(string flow_name) { if (story == null) { PushNullStoryError(); return; } try { story.SwitchFlow(flow_name); } catch (Exception e) { HandleException(e); } } public void switch_to_default_flow() { if (story == null) { PushNullStoryError(); return; } try { story.SwitchToDefaultFlow(); } catch (Exception e) { HandleException(e); } } public void remove_flow(string flow_name) { if (story == null) { PushNullStoryError(); return; } try { story.RemoveFlow(flow_name); } catch (Exception e) { HandleException(e); } } #endregion #region Methods | Tags public Godot.Collections.Array tags_for_content_at_path(string path) { if (story == null) { PushNullStoryError(); return new Godot.Collections.Array(); } try { var tags = story.TagsForContentAtPath(path); if (tags != null) { return new Godot.Collections.Array(tags); } else { return new Godot.Collections.Array(); } } catch (Exception e) { HandleException(e); return new Godot.Collections.Array(); } } public int visit_count_at_path(string path) { if (story == null) { PushNullStoryError(); return 0; } try { return story.state.VisitCountAtPathString(path); } catch (Exception e) { HandleException(e); return 0; } } #endregion #region Methods | State Management public string get_state() { if (story == null) { PushNullStoryError(); return ""; } try { return story.state.ToJson(); } catch (Exception e) { HandleException(e); return ""; } } public string copy_state_for_background_thread_save() { if (story == null) { PushNullStoryError(); return ""; } return story.CopyStateForBackgroundThreadSave().ToJson(); } public void background_save_complete() { if (story == null) { PushNullStoryError(); return; } story.BackgroundSaveComplete(); } public void set_state(string state) { if (story == null) { PushNullStoryError(); return; } try { story.state.LoadJson(state); } catch (Exception e) { HandleException(e); } } public void save_state_to_path(string path) { if (story == null) { PushNullStoryError(); return; } string sanitizedPath; if (!path.StartsWith("res://") && !path.StartsWith("user://")) { sanitizedPath = $"user://{path}"; } else { sanitizedPath = path; } var file = new File(); file.Open(sanitizedPath, File.ModeFlags.Write); save_state_to_file(file); file.Close(); } public void save_state_to_file(Godot.File file) { if (story == null) { PushNullStoryError(); return; } if (file.IsOpen()) { file.StoreString(get_state()); } } public void load_state_from_path(string path) { if (story == null) { PushNullStoryError(); return; } string sanitizedPath; if (!path.StartsWith("res://") && !path.StartsWith("user://")) { sanitizedPath = $"user://{path}"; } else { sanitizedPath = path; } var file = new File(); file.Open(sanitizedPath, File.ModeFlags.Read); load_state_from_file(file); file.Close(); } public void load_state_from_file(Godot.File file) { if (story == null) { PushNullStoryError(); return; } if (!file.IsOpen()) { return; } file.Seek(0); if (file.GetLength() > 0) { story.state.LoadJson(file.GetAsText()); } } #endregion #region Methods | Variables public object get_variable(string name) { if (story == null) { PushNullStoryError(); return null; } try { var variable = story.variablesState[name]; if (variable is Ink.Runtime.InkList inkList) { return inkBridger.MakeGDInkList(inkList); } if (variable is Ink.Runtime.Path3D path) { return inkBridger.MakeGDInkPath(path); } } catch(Exception e) { HandleException(e); return null; } return story.variablesState[name]; } public void set_variable(string name, object value) { if (story == null) { PushNullStoryError(); return; } try { if (value is Godot.Object gdObject) { if (IsInkObject(gdObject, "InkList")) { story.variablesState[name] = inkBridger.MakeSharpInkList(gdObject, story); return; } if (IsInkObject(gdObject, "InkPath")) { story.variablesState[name] = inkBridger.MakeSharpInkPath(gdObject); return; } } story.variablesState[name] = value; } catch (Exception e) { HandleException(e); } } #endregion #region Methods | Variable Observers public void observe_variables(Godot.Collections.Array variable_names, Godot.Object gd_object, string method_name) { if (story == null) { PushNullStoryError(); return; } try { foreach(string variable_name in variable_names) { observe_variable(variable_name, gd_object, method_name); } } catch (Exception e) { HandleException(e); } } public void observe_variable(string variable_name, Godot.Object gd_object, string method_name) { if (story == null) { PushNullStoryError(); return; } try { var instanceId = (int)gd_object.Call("get_instance_id"); var funcRef = GD.FuncRef(gd_object, method_name); var functionReference = new FunctionReference( variable_name, instanceId, method_name, (string variableName, object value) => { funcRef.CallFuncv(new Godot.Collections.Array() { variableName, value }); } ); if (observers.TryGetValue(variable_name, out List referenceList)) { referenceList.Append(functionReference); } else { observers[variable_name] = new List () { functionReference }; } story.ObserveVariable(variable_name, functionReference.observer); } catch (Exception e) { HandleException(e); } } public void remove_variable_observer(Godot.Object gd_object, string method_name, string specific_variable_name) { if (story == null) { PushNullStoryError(); return; } try { if (observers.TryGetValue(specific_variable_name, out List references)) { var validReferences = references.FindAll(reference => reference.Matches(specific_variable_name, gd_object, method_name)); foreach(FunctionReference reference in validReferences) { story.RemoveVariableObserver(reference.observer, specific_variable_name); references.Remove(reference); } } } catch (Exception e) { HandleException(e); } } public void remove_variable_observer_for_all_variables(Godot.Object gd_object, string method_name) { if (story == null) { PushNullStoryError(); return; } try { List observersToRemove = new List(); foreach(KeyValuePair> kv in observers) { var validReferences = kv.Value.FindAll(reference => reference.Matches(gd_object, method_name)); foreach(FunctionReference reference in validReferences) { story.RemoveVariableObserver(reference.observer); kv.Value.Remove(reference); } } } catch (Exception e) { HandleException(e); } } public void remove_all_variable_observers(string specific_variable_name) { if (story == null) { PushNullStoryError(); return; } try { story.RemoveVariableObserver(null, specific_variable_name); observers.Remove(specific_variable_name); } catch (Exception e) { HandleException(e); } } #endregion #region Methods | External Functions public void bind_external_function( string func_name, Godot.Object gd_object, string method_name) { bind_external_function(func_name, gd_object, method_name, false); } public void bind_external_function( string func_name, Godot.Object gd_object, string method_name, bool lookahead_safe) { if (story == null) { PushNullStoryError(); return; } try { story.BindExternalFunctionGeneral( func_name, (object[] args) => gd_object.Call(method_name, args), lookahead_safe); } catch (Exception e) { HandleException(e); } } public void unbind_external_function(string func_name) { if (story == null) { PushNullStoryError(); return; } try { story.UnbindExternalFunction(func_name); } catch (Exception e) { HandleException(e); } } #endregion #region Methods | Functions public bool has_function(string function_name) { if (story == null) { PushNullStoryError(); return false; } return story.HasFunction(function_name); } public Godot.Object evaluate_function(string function_name, object[] arguments) { if (story == null) { PushNullStoryError(); return null; } // Prevents sending `null` to EvaluateFunction, which would // otherwise raise an exception. object[] sanitizedArguments; if (arguments != null) { sanitizedArguments = arguments; } else { sanitizedArguments = new object[] { }; } try { object returnValue = story.EvaluateFunction(function_name, out string textOutput, sanitizedArguments); return inkBridger.MakeFunctionResult(textOutput, returnValue); } catch (Exception e) { HandleException(e); return null; } } #endregion #region Methods | Ink List Creation public Godot.Object create_ink_list_with_origin(string single_origin_list_name) { if (story == null) { PushNullStoryError(); return null; } try { var inkList = new Ink.Runtime.InkList(single_origin_list_name, story); return inkBridger.MakeGDInkList(inkList); } catch (Exception e) { HandleException(e); return null; } } public Godot.Object create_ink_list_from_item_name(string item_name) { if (story == null) { PushNullStoryError(); return null; } try { var inkList = Ink.Runtime.InkList.FromString(item_name, story); return inkBridger.MakeGDInkList(inkList); } catch (Exception e) { HandleException(e); return null; } } #endregion #region Event Handlers private void onError(string message, Ink.Ink.ErrorType type) { if (stop_execution_on_error) { PushStoryError(message, type); Debug.Assert(false, "Error encountered, check the debugger tab for more information."); return; } if (GetSignalConnectionList("error_encountered").Count == 0) { PushStoryError(message, type); } else { EmitSignal("error_encountered", new object[] { message, type }); } } private void onDidContinue() { EmitSignal("continued", new object[] { current_text, current_tags }); } private void onMakeChoice(Ink.Runtime.Choice choice) { EmitSignal("choice_made", new object[] { choice }); } private void onEvaluateFunction(string functionName, object[] arguments) { EmitSignal("function_evaluating", new object[] { functionName, arguments }); } private void onCompleteEvaluateFunction( string functionName, object[] arguments, string textOuput, object returnValue) { var functionResult = inkBridger.MakeFunctionResult(textOuput, returnValue); var parameters = new object[] { functionName, arguments, functionResult }; EmitSignal("function_evaluated", parameters); } private void onChoosePathString(string path, object[] arguments) { EmitSignal("path_choosen", new object[] { path, arguments }); } #endregion #region Private Methods private void CreateStory(string json_story) { story = new Ink.Runtime.Story(json_story); } private void AsyncCreateStory(string json_story) { CreateStory(json_story); CallDeferred("AsyncCreationCompleted"); } private void AsyncCreationCompleted() { thread.WaitToFinish(); thread = null; FinaliseStoryCreation(); } private void FinaliseStoryCreation() { story.onError += onError; story.onDidContinue += onDidContinue; story.onMakeChoice += onMakeChoice; story.onEvaluateFunction += onEvaluateFunction; story.onCompleteEvaluateFunction += onCompleteEvaluateFunction; story.onChoosePathString += onChoosePathString; EmitSignal("loaded", new object[] { true }); } private bool CurrentPlatformSupportsThreads() { return OS.GetName() != "HTML5"; } private void PushNullStoryError() { PushError("The story is 'null', was it loaded properly?"); } private void PushNullStateError(string variable) { var message = $"'{variable}' is 'null', the internal state is corrupted or missing, this is an unrecoverable error."; PushError(message); } private void PushStoryError(string message, Ink.Ink.ErrorType type) { if (Engine.EditorHint) { switch (type) { case Ink.Ink.ErrorType.Error: GD.PrintErr(message); break; case Ink.Ink.ErrorType.Warning: case Ink.Ink.ErrorType.Author: GD.Print(message); break; } } else { switch (type) { case Ink.Ink.ErrorType.Error: GD.PushError(message); break; case Ink.Ink.ErrorType.Warning: case Ink.Ink.ErrorType.Author: GD.PushWarning(message); break; } } } private void PushError(string message) { if (Engine.EditorHint) { GD.PrintErr(message); foreach(string line in System.Environment.StackTrace.Split("\n")) { GD.PrintErr(line); } } else { GD.PushError(message); } } private bool IsValidResource(Resource resource) { var properties = resource.GetPropertyList().Cast().ToList(); return ( properties.Exists(element => "json".Equals(element["name"])) && resource.Get("json") is string ); } private bool IsInkObject(Godot.Object inkObject, string name) { return inkObject.HasMethod("is_ink_class") && (bool)inkObject.Call("is_ink_class", new object[] { name }); } private void HandleException(Exception e) { var message = $"{GetExceptionType(e)}: {e.Message}"; if (ShouldStopExecution(e)) { GD.PrintErr(message); foreach(string line in e.StackTrace.Split("\n")) { GD.PrintErr(line); } Debug.Assert(false, "Exception encountered, check the output tab for more information."); } else { EmitSignal("exception_raised", new object[] { message, e.StackTrace.Split("\n") }); } } private bool ShouldStopExecution(Exception e) { return ( (e is Ink.Runtime.StoryException && stop_execution_on_error) || (!(e is Ink.Runtime.StoryException) && stop_execution_on_exception) ); } private string GetExceptionType(Exception e) { if (e is Ink.Runtime.StoryException) { return "STORY EXCEPTION"; } else if (e is System.ArgumentException) { return "ARGUMENT EXCEPTION"; } else { return "EXCEPTION"; } } #endregion #region Private Structures private readonly struct FunctionReference { public readonly string variableName; public readonly int objectReferenceId; public readonly string methodName; public readonly Ink.Runtime.Story.VariableObserver observer; public FunctionReference( string variableName, int objectReferenceId, string methodName, Ink.Runtime.Story.VariableObserver observer) { this.variableName = variableName; this.objectReferenceId = objectReferenceId; this.methodName = methodName; this.observer = observer; } public bool Matches(string variableName, Godot.Object gdObject, string methodName) { return this.variableName.Equals(variableName) && Matches(gdObject, methodName); } public bool Matches(Godot.Object gdObject, string methodName) { return ( (int)gdObject.Call("get_instance_id") == objectReferenceId && this.methodName.Equals(methodName) ); } public override string ToString() { return $"FunctionReference[{variableName}, {objectReferenceId}, {methodName}]"; } } #endregion }