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

1271 lines
24 KiB
C#

// /////////////////////////////////////////////////////////////////////////// /
// 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.
// /////////////////////////////////////////////////////////////////////////// /
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<string> 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<string> tags);
[Signal] public delegate void interrupted();
[Signal] public delegate void prompt_choices(Godot.Collections.Array<Godot.Object> choices);
[Signal] public delegate void choice_made(Godot.Object choice);
[Signal] public delegate void function_evaluating(Godot.Collections.Array<string> function_name, object[] arguments);
[Signal] public delegate void function_evaluated(Godot.Collections.Array<string> 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<string> current_choices
{
get {
if (story == null)
{
PushNullStoryError();
return new Godot.Collections.Array<string>();
}
if (story.currentChoices == null)
{
PushNullStateError("current_choices");
return new Godot.Collections.Array<string>();
}
if (choices != null) {
return new Godot.Collections.Array<string>(story.currentChoices);
} else {
return new Godot.Collections.Array<string>();
}
}
}
public Godot.Collections.Array<string> current_tags
{
get {
if (story == null)
{
PushNullStoryError();
return new Godot.Collections.Array<string>();
}
if (story.currentTags == null)
{
PushNullStateError("current_tags");
return new Godot.Collections.Array<string>();
}
if (story.currentTags != null) {
return new Godot.Collections.Array<string>(story.currentTags);
} else {
return new Godot.Collections.Array<string>();
}
}
}
public Godot.Collections.Array<string> global_tags
{
get {
if (story == null)
{
PushNullStoryError();
return new Godot.Collections.Array<string>();
}
if (story.globalTags != null) {
return new Godot.Collections.Array<string>(story.globalTags);
} else {
return new Godot.Collections.Array<string>();
}
}
}
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<string, List<FunctionReference>> observers =
new Dictionary<string, List<FunctionReference>>();
#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<string> tags_for_content_at_path(string path)
{
if (story == null)
{
PushNullStoryError();
return new Godot.Collections.Array<string>();
}
try
{
var tags = story.TagsForContentAtPath(path);
if (tags != null) {
return new Godot.Collections.Array<string>(tags);
} else {
return new Godot.Collections.Array<string>();
}
}
catch (Exception e)
{
HandleException(e);
return new Godot.Collections.Array<string>();
}
}
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<string> 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<FunctionReference> referenceList))
{
referenceList.Append(functionReference);
}
else
{
observers[variable_name] = new List<FunctionReference> () { 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<FunctionReference> 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<string> observersToRemove = new List<string>();
foreach(KeyValuePair<string, List<FunctionReference>> 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<Godot.Collections.Dictionary>().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
}