/* Yarn Spinner is licensed to you under the terms found in the file LICENSE.md. */ using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Reflection; using System.Threading.Tasks; using UnityEngine; #nullable enable namespace Yarn.Unity { using ActionRegistrationMethod = System.Action; using Converter = System.Func; public enum RegistrationType { /// /// Actions are being registered during a Yarn script compilation. /// Compilation, /// /// Actions are being registered for runtime use (i.e. during gameplay.) /// Runtime } public interface ICommand { string Name { get; } } internal static class DiagnosticUtility { public static string EnglishPluraliseNounCount(int count, string name, bool prefixCount = false) { string result = count == 1 ? name : name + "s"; return prefixCount ? $"{count} {result}" : result; } public static string EnglishPluraliseWasVerb(int count) => count == 1 ? "was" : "were"; } public class Actions : ICommandDispatcher { internal class CommandRegistration : ICommand { public CommandRegistration(string name, Delegate @delegate) { Name = name; Method = @delegate.Method; Target = @delegate.Target; Converters = CreateConverters(Method); DynamicallyFindsTarget = false; } public CommandRegistration(string name, MethodInfo method) { if (method.IsStatic) { DynamicallyFindsTarget = false; } else if (typeof(Component).IsAssignableFrom(method.DeclaringType)) { // This method is an instance method on a Component (or one // of its subclasses). We'll dynamically find a target to // invoke the method on at runtime. DynamicallyFindsTarget = true; } else { // The instance method's declaring type is not a Component, // which means we won't be able to look up a target. throw new ArgumentException($"Cannot register method {GetFullMethodName(method)} as a command: instance methods must declared on {nameof(Component)} classes."); } Name = name; Method = method; Target = null; Converters = CreateConverters(method); } public string Name { get; set; } public MethodInfo Method { get; set; } private object? Target { get; set; } public Type DeclaringType => Method.DeclaringType; public Type ReturnType => Method.ReturnType; public bool IsStatic => Method.IsStatic; public readonly Converter[] Converters; /// /// Gets a value indicating that this command finds a target to /// invoke its method on by name, each time it is invoked. /// private bool DynamicallyFindsTarget { get; } public CommandType Type { get { Type returnType = ReturnType; if (typeof(void).IsAssignableFrom(returnType)) { return CommandType.IsVoid; } if (typeof(IEnumerator).IsAssignableFrom(returnType)) { return CommandType.IsCoroutine; } if (typeof(Coroutine).IsAssignableFrom(returnType)) { return CommandType.ReturnsCoroutine; } return CommandType.Invalid; } } public enum CommandType { /// /// The method returns . /// IsVoid, /// /// The method returns a object. /// /// ReturnsCoroutine, /// /// The method returns (that is, it is /// a coroutine). /// /// /// Code that invokes this command should use to begin /// the coroutine. /// IsCoroutine, /// /// The method is not a valid command (that is, it does not /// return , , or .) /// Invalid, } /// /// Attempt to parse the arguments with cached converters. /// public CommandDispatchResult.ParameterParseStatusType TryParseArgs(string[] args, out object?[]? result, out string? message) { var parameters = Method.GetParameters(); var lastParameterIsArray = parameters.Length > 0 && parameters[parameters.Length - 1].ParameterType.IsArray; var (min, max) = ParameterCount; int argumentCount = args.Length; if (argumentCount < min || (argumentCount > max && !lastParameterIsArray)) { // Wrong number of arguments. string requirementDescription; if (min == 0) { requirementDescription = $"at most {max} {DiagnosticUtility.EnglishPluraliseNounCount(max, "parameter")}"; } else if (min != max) { requirementDescription = $"between {min} and {max} {DiagnosticUtility.EnglishPluraliseNounCount(max, "parameter")}"; } else { requirementDescription = $"{min} {DiagnosticUtility.EnglishPluraliseNounCount(max, "parameter")}"; } message = $"{this.Name} requires {requirementDescription}, but {argumentCount} {DiagnosticUtility.EnglishPluraliseWasVerb(argumentCount)} provided."; result = default; return CommandDispatchResult.ParameterParseStatusType.InvalidParameterCount; } var finalArgs = new object?[parameters.Length]; var argsQueue = new Queue(args); var paramsArgs = new List(); for (int i = 0; i < argumentCount; i++) { var parameterIsArray = parameters[i].ParameterType.IsArray; string arg = args[i]; Converter converter = Converters[i]; if (parameterIsArray) { if (i < parameters.Length - 1) { // The parameter is an array, but it isn't the last // parameter. That's not allowed. message = $"Parameter {i} ({parameters[i].Name}): is an array, but is not the last parameter of {parameters[i].Member.Name}."; result = default; return CommandDispatchResult.ParameterParseStatusType.InvalidParameterType; } // Consume all remaining arguments, passing them through // the final converter, and produce an array from the // results. This array will be the final parameter to // the method. var parameterArrayElementType = parameters[i].ParameterType.GetElementType(); var paramIndex = i; // var paramsArray = new List(); var paramsArray = Array.CreateInstance(parameterArrayElementType, argumentCount - i); while (i < argumentCount) { arg = args[i]; if (converter == null) { // Use relative index into paramsArray paramsArray.SetValue(arg, i - paramIndex); } else { try { paramsArray.SetValue(converter.Invoke(arg, i), i - paramIndex); } catch (Exception e) { message = $"Can't convert parameter {i} to {parameterArrayElementType.Name}: {e.Message}"; result = default; return CommandDispatchResult.ParameterParseStatusType.InvalidParameterType; } } i += 1; } finalArgs[paramIndex] = paramsArray; } else { // Consume a single argument if (converter == null) { finalArgs[i] = arg; } else { try { finalArgs[i] = converter.Invoke(arg, i); } catch (Exception e) { message = $"Can't convert parameter {i} to {parameters[i].ParameterType.Name}: {e.Message}"; result = default; return CommandDispatchResult.ParameterParseStatusType.InvalidParameterType; } } } } for (int i = argumentCount; i < finalArgs.Length; i++) { var parameter = parameters[i]; if (parameter.IsOptional) { // If this parameter is optional, provide the Missing // type. finalArgs[i] = System.Type.Missing; } else if (parameter.GetCustomAttribute() != null) { // If the parameter is a params array, provide an empty // array of the appropriate type. finalArgs[i] = Array.CreateInstance(parameter.ParameterType.GetElementType(), 0); } else { throw new InvalidOperationException($"Can't provide a default value for parameter {parameter.Name}"); } } result = finalArgs; message = default; return CommandDispatchResult.ParameterParseStatusType.Succeeded; } private (int Min, int Max) ParameterCount { get { var parameters = Method.GetParameters(); int optional = 0; bool lastCommandIsParams = false; foreach (var parameter in parameters) { if (parameter.IsOptional) { optional += 1; } if (parameter.ParameterType.IsArray && parameter.GetCustomAttribute() != null) { // If the parameter is a params array, then: // 1. It's 'optional' in that you can pass in no // values (so, for our purposes, the minimum // number of parameters you need to pass is not // changed) // 2. The maximum number of parameters you can pass // is now effectively unbounded. lastCommandIsParams = true; optional += 1; } } int min = parameters.Length - optional; int max = parameters.Length; if (lastCommandIsParams) { max = int.MaxValue; } return (min, max); } } internal CommandDispatchResult Invoke(MonoBehaviour dispatcher, List parameters) { object? target; if (DynamicallyFindsTarget) { // We need to find a target to call this method on. if (parameters.Count == 0) { // We need at least one parameter, which is the // component to look for return new CommandDispatchResult(CommandDispatchResult.StatusType.InvalidParameterCount, YarnTask.CompletedTask) { Message = $"{this.Name} needs a target, but none was specified", }; } // First parameter is the name of a game object that has the // component we're trying to call. var gameObjectName = parameters[0]; parameters.RemoveAt(0); var gameObject = GameObject.Find(gameObjectName); if (gameObject == null) { // We couldn't find a target with this name. return new CommandDispatchResult(CommandDispatchResult.StatusType.TargetMissingComponent) { Message = $"No game object named \"{gameObjectName}\" exists", }; } // We've found a target. Does it have a component that's // the right type of object to call the method on? var targetComponent = gameObject.GetComponent(this.DeclaringType); if (targetComponent == null) { return new CommandDispatchResult(CommandDispatchResult.StatusType.TargetMissingComponent) { Message = $"{this.Name} can't be called on {gameObjectName}, because it doesn't have a {this.DeclaringType.Name}", }; } target = targetComponent; } else if (Method.IsStatic) { // The method is static; it therefore doesn't need a target. target = null; } else if (Target != null) { // The method is an instance method, so use the target we've // stored. target = Target; } else { // We don't know what to call this method on. throw new InvalidOperationException($"Internal error: {nameof(CommandRegistration)} \"{this.Name}\" has no {nameof(Target)}, but method is not static and ${DynamicallyFindsTarget} is false"); } var parseArgsStatus = this.TryParseArgs(parameters.ToArray(), out var finalParameters, out var errorMessage); if (parseArgsStatus != CommandDispatchResult.ParameterParseStatusType.Succeeded) { var status = parseArgsStatus switch { CommandDispatchResult.ParameterParseStatusType.Succeeded => CommandDispatchResult.StatusType.Succeeded, CommandDispatchResult.ParameterParseStatusType.InvalidParameterType => CommandDispatchResult.StatusType.InvalidParameter, CommandDispatchResult.ParameterParseStatusType.InvalidParameterCount => CommandDispatchResult.StatusType.InvalidParameterCount, _ => throw new InvalidOperationException("Internal error: invalid parameter parse result " + parseArgsStatus), }; return new CommandDispatchResult(status) { Message = errorMessage, }; } var returnValue = this.Method.Invoke(target, finalParameters); if (returnValue is Coroutine coro) { // The method returned a Coroutine object. return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, dispatcher.WaitForCoroutine(coro) ); } else if (returnValue is YarnTask yarnTask) { return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, yarnTask ); } #if UNITY_2023_1_OR_NEWER else if (returnValue is Awaitable awaitable) { // The method returned an Awaitable. Convert it to a // YarnTask. (Awaitables implement IEnumerator, so check // this before testing against other IEnumerators like // coroutines.) return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, awaitable ); } #endif else if (returnValue is IEnumerator enumerator) { // The method returned an IEnumerator. return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, dispatcher.WaitForCoroutine(enumerator) ); } else if (returnValue is System.Threading.Tasks.Task task) { // The method returned a task. Convert it to a YarnTask. return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, task ); } #if USE_UNITASK else if (returnValue is Cysharp.Threading.Tasks.UniTask unitask) { // The method returned a UniTask. Convert it to a YarnTask. return new CommandDispatchResult( CommandDispatchResult.StatusType.Succeeded, unitask ); } #endif else { // The method returned no value. return new CommandDispatchResult(CommandDispatchResult.StatusType.Succeeded); } } public string UsageString { get { var components = new List(); components.Add(Name); if (DynamicallyFindsTarget) { var declaringTypeName = DeclaringType.Name; components.Add($"target ({declaringTypeName})"); } foreach (var parameter in Method.GetParameters()) { var type = parameter.ParameterType; string typeName; if (TypeFriendlyNames.TryGetValue(type, out typeName) == false) { typeName = type.Name; } string displayName = $"{parameter.Name} ({typeName})"; if (parameter.IsOptional) { displayName = $"[{displayName} = {parameter.DefaultValue}]"; } components.Add(displayName); } return string.Join(" ", components); } } readonly Dictionary TypeFriendlyNames = new Dictionary { { typeof(int), "number" }, { typeof(float), "number" }, { typeof(double), "number" }, { typeof(Decimal), "number" }, { typeof(string), "string" }, { typeof(bool), "bool" }, }; } private Dictionary _commands = new Dictionary(); public Library Library { get; } public IActionRegistration ActionRegistrar { get; } public IEnumerable Commands => _commands.Values; public Actions(IActionRegistration actionRegistrar, Library library) { Library = library; ActionRegistrar = actionRegistrar; } private static string GetFullMethodName(MethodInfo method) { return $"{method.DeclaringType.FullName}.{method.Name}"; } public void RegisterActions() { foreach (var registrationFunction in ActionRegistrationMethods) { registrationFunction.Invoke(ActionRegistrar, RegistrationType.Runtime); } } public void AddCommandHandler(string commandName, Delegate handler) { if (commandName.Contains(' ')) { Debug.LogError($"Failed to register command {commandName}: command names are not allowed to contain spaces."); return; } if (_commands.ContainsKey(commandName)) { Debug.LogError($"Failed to register command {commandName}: a command by this name has already been registered."); return; } else { #if YARN_SOURCE_GENERATION_DEBUG_LOGGING Debug.Log($"Registering command {commandName}"); #endif _commands.Add(commandName, new CommandRegistration(commandName, handler)); } } public void AddFunction(string name, Delegate implementation) { if (name.Contains(' ')) { Debug.LogError($"Cannot add function {name}: function names are not allowed to contain spaces."); return; } if (Library.FunctionExists(name)) { Debug.LogError($"Cannot add function {name}: one already exists"); return; } #if YARN_SOURCE_GENERATION_DEBUG_LOGGING Debug.Log($"Registering command {name} from method {implementation.Method.DeclaringType.FullName}.{implementation.Method.Name}"); #endif Library.RegisterFunction(name, implementation); } public void AddCommandHandler(string commandName, MethodInfo methodInfo) { if (commandName.Contains(' ')) { Debug.LogError($"Failed to register command {commandName}: command names are not allowed to contain spaces."); return; } if (_commands.ContainsKey(commandName)) { Debug.LogError($"Failed to register command {commandName}: a command by this name has already been registered."); return; } else { _commands.Add(commandName, new CommandRegistration(commandName, methodInfo)); } } public void RemoveCommandHandler(string commandName) { if (_commands.Remove(commandName) == false) { Debug.LogError($"Can't remove command {commandName}, because no command with this name is currently registered."); } } public void RemoveFunction(string name) { if (Library.FunctionExists(name) == false) { Debug.LogError($"Cannot remove function {name}: no function with that name exists in the library"); return; } Library.DeregisterFunction(name); } public void SetupForProject(YarnProject yarnProject) { // no-op } CommandDispatchResult ICommandDispatcher.DispatchCommand(string command, MonoBehaviour coroutineHost) { var commandPieces = new List(DialogueRunner.SplitCommandText(command)); if (commandPieces.Count == 0) { // No text was found inside the command, so we won't be able to // find it. return new CommandDispatchResult(CommandDispatchResult.StatusType.CommandUnknown, YarnTask.CompletedTask); } if (_commands.TryGetValue(commandPieces[0], out var registration)) { // The first part of the command is the command name itself. // Remove it to get the collection of parameters that were // passed to the command. commandPieces.RemoveAt(0); return registration.Invoke(coroutineHost, commandPieces); } else { return new CommandDispatchResult(CommandDispatchResult.StatusType.CommandUnknown); } } private static Converter[] CreateConverters(MethodInfo method) { ParameterInfo[] parameterInfos = method.GetParameters(); Converter[] result = new Converter[parameterInfos.Length]; int i = 0; foreach (var parameterInfo in parameterInfos) { if (parameterInfo.ParameterType.IsArray) { // Array parameters are permitted, but only if they're the // last parameter if (i != parameterInfos.Length - 1) { throw new ArgumentException($"Can't register method {method.Name}: Parameter {i + 1} ({parameterInfo.Name}): array parameters are required to be last."); } } result[i] = CreateConverter(parameterInfo, i); i++; } return result; } private static System.Func CreateConverter(ParameterInfo parameter, int index) { var targetType = parameter.ParameterType; string name = parameter.Name; if (targetType.IsArray) { // This parameter is a params array. Make a converter for that // array's element type; at dispatch time, we'll repeatedly call // it with the arguments found in the command. var paramsArrayType = targetType.GetElementType(); var elementConverter = CreateConverterFunction(paramsArrayType, name); return elementConverter; } else { // This parameter is for a single value. Make a converter that // receives a single string, return CreateConverterFunction(targetType, name); } } private static Converter CreateConverterFunction(Type targetType, string parameterName) { // well, I mean... if (targetType == typeof(string)) { return (arg, i) => arg; } // find the GameObject. if (typeof(GameObject).IsAssignableFrom(targetType)) { return (arg, i) => GameObject.Find(arg); } // find components of the GameObject with the component, if // available if (typeof(Component).IsAssignableFrom(targetType)) { return (arg, i) => { GameObject gameObject = GameObject.Find(arg); if (gameObject == null) { return null; } return gameObject.GetComponentInChildren(targetType); }; } // bools can take "true" or "false", or the parameter name. if (typeof(bool).IsAssignableFrom(targetType)) { return (arg, i) => { // If the argument is the name of the parameter, interpret // the argument as 'true'. if (arg.Equals(parameterName, StringComparison.InvariantCultureIgnoreCase)) { return true; } // If the argument can be parsed as boolean true or false, // return that result. if (bool.TryParse(arg, out bool res)) { return res; } // We can't parse the argument. throw new ArgumentException( $"Can't convert the given parameter at position {i + 1} (\"{arg}\") to parameter " + $"{parameterName} of type {typeof(bool).FullName}."); }; } // Fallback: try converting using IConvertible. return (arg, i) => { try { return Convert.ChangeType(arg, targetType, CultureInfo.InvariantCulture); } catch (Exception e) { throw new ArgumentException( $"Can't convert the given parameter at position {i + 1} (\"{arg}\") to parameter " + $"{parameterName} of type {targetType.FullName}: {e}", e); } }; } internal static HashSet ActionRegistrationMethods = new HashSet(); public static void AddRegistrationMethod(ActionRegistrationMethod registerActions) { ActionRegistrationMethods.Add(registerActions); } public void AddCommandHandler(string commandName, Func handler) { this.AddCommandHandler(commandName, (Delegate)handler); } public void RegisterFunctionDeclaration(string name, Type returnType, Type[] parameterTypes) { /* no-op */ } } }