Skip to content

Commit

Permalink
Add support for variadic YarnFunctions and commands
Browse files Browse the repository at this point in the history
  • Loading branch information
desplesda committed May 23, 2024
1 parent c009d9f commit 3adb295
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Updated voiceover and translation credits for the Intro sample scene.
- Added shadow line support to BuiltInLineProvider.
- Added support for generating C# variable storage classes that expose properties for string, number and boolean variables found in a Yarn Project.
- `YarnCommand` and `YarnFunction` methods may now use `params` array parameters.

### Changed

Expand Down
8 changes: 8 additions & 0 deletions Runtime/CommandDispatchResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ namespace Yarn.Unity
internal struct CommandDispatchResult
{

internal enum ParameterParseStatusType {
Succeeded,
InvalidParameterType,
InvalidParameterCount,
}

internal enum StatusType
{

Expand All @@ -37,6 +43,8 @@ internal enum StatusType

InvalidParameterCount,

InvalidParameter,

/// <summary>
/// The command could not be found.
/// </summary>
Expand Down
161 changes: 133 additions & 28 deletions Runtime/Commands/Actions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.

namespace Yarn.Unity
{
using Converter = System.Func<string, object?>;
using Converter = System.Func<string, int, object?>;
using ActionRegistrationMethod = System.Action<IActionRegistration, RegistrationType>;


public enum RegistrationType {
/// <summary>
/// Actions are being registered during a Yarn script compilation.
Expand Down Expand Up @@ -176,7 +177,7 @@ public enum CommandType
/// <summary>
/// Attempt to parse the arguments with cached converters.
/// </summary>
public bool TryParseArgs(string[] args, out object?[]? result, out string? message)
public CommandDispatchResult.ParameterParseStatusType TryParseArgs(string[] args, out object?[]? result, out string? message)
{
var parameters = Method.GetParameters();

Expand All @@ -195,49 +196,123 @@ public bool TryParseArgs(string[] args, out object?[]? result, out string? messa
}
message = $"{this.Name} requires {requirementDescription}, but {argumentCount} {DiagnosticUtility.EnglishPluraliseWasVerb(argumentCount)} provided.";
result = default;
return false;
return CommandDispatchResult.ParameterParseStatusType.InvalidParameterCount;
}

var finalArgs = new object?[parameters.Length];

var argsQueue = new Queue(args);

for (int i = 0; i < argumentCount; i++)
{
var parameterIsParamsArray = parameters[i].GetCustomAttribute<ParamArrayAttribute>() != null;

string arg = args[i];
if (Converters[i] == null) {
finalArgs[i] = arg;
} else {
try {
finalArgs[i] = Converters[i].Invoke(arg);
} catch (Exception e) {
message = $"Can't convert parameter {i} to {parameters[i].ParameterType.Name}: {e.Message}";
result = default;
return false;
Converter converter = Converters[i];

if (parameterIsParamsArray) {
// 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<object?>();
var paramsArray = Array.CreateInstance(parameterArrayElementType, argumentCount - i);
while (i < argumentCount) {
arg = args[i];
if (converter == null) {
paramsArray.SetValue(arg, i);
}
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++)
{
finalArgs[i] = System.Type.Missing;
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<ParamArrayAttribute>() != 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 true;
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<ParamArrayAttribute>()!= 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);
}
}
Expand Down Expand Up @@ -303,9 +378,19 @@ internal CommandDispatchResult Invoke(MonoBehaviour dispatcher, List<string> par
throw new InvalidOperationException($"Internal error: {nameof(CommandRegistration)} \"{this.Name}\" has no {nameof(Target)}, but method is not static and ${DynamicallyFindsTarget} is false");
}

if (this.TryParseArgs(parameters.ToArray(), out var finalParameters, out var errorMessage) == false)
var parseArgsStatus = this.TryParseArgs(parameters.ToArray(), out var finalParameters, out var errorMessage);

if (parseArgsStatus != CommandDispatchResult.ParameterParseStatusType.Succeeded)
{
return new CommandDispatchResult(CommandDispatchResult.StatusType.InvalidParameterCount)
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,
};
Expand Down Expand Up @@ -507,7 +592,7 @@ private static Converter[] CreateConverters(MethodInfo method)
{
ParameterInfo[] parameterInfos = method.GetParameters();

Converter[] result = (Func<string, object>[])Array.CreateInstance(typeof(Func<string, object>), parameterInfos.Length);
Converter[] result = new Converter[parameterInfos.Length];

int i = 0;

Expand All @@ -519,23 +604,43 @@ private static Converter[] CreateConverters(MethodInfo method)
return result;
}

private static Converter CreateConverter(ParameterInfo parameter, int index)
private static System.Func<string, int, object?> CreateConverter(ParameterInfo parameter, int index)
{
var targetType = parameter.ParameterType;
string name = parameter.Name;
var parameterIsParamsArray = parameter.GetCustomAttribute<ParamArrayAttribute>() != null;

if (targetType.IsArray && parameterIsParamsArray) {
// 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 => arg; }
if (targetType == typeof(string)) { return (arg, i) => arg; }

// find the GameObject.
if (typeof(GameObject).IsAssignableFrom(targetType))
{
return GameObject.Find;
return (arg, i) => GameObject.Find(arg);
}

// find components of the GameObject with the component, if available
if (typeof(Component).IsAssignableFrom(targetType))
{
return arg =>
return (arg, i) =>
{
GameObject gameObject = GameObject.Find(arg);
if (gameObject == null)
Expand All @@ -549,11 +654,11 @@ private static Converter CreateConverter(ParameterInfo parameter, int index)
// bools can take "true" or "false", or the parameter name.
if (typeof(bool).IsAssignableFrom(targetType))
{
return arg =>
return (arg, i) =>
{
// If the argument is the name of the parameter, interpret
// the argument as 'true'.
if (arg.Equals(parameter.Name, StringComparison.InvariantCultureIgnoreCase))
if (arg.Equals(parameterName, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
Expand All @@ -567,13 +672,13 @@ private static Converter CreateConverter(ParameterInfo parameter, int index)
// We can't parse the argument.
throw new ArgumentException(
$"Can't convert the given parameter at position {index + 1} (\"{arg}\") to parameter " +
$"{parameter.Name} of type {typeof(bool).FullName}.");
$"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 =>
return (arg, i) =>
{
try
{
Expand All @@ -582,8 +687,8 @@ private static Converter CreateConverter(ParameterInfo parameter, int index)
catch (Exception e)
{
throw new ArgumentException(
$"Can't convert the given parameter at position {index + 1} (\"{arg}\") to parameter " +
$"{parameter.Name} of type {targetType.FullName}: {e}", e);
$"Can't convert the given parameter at position {i + 1} (\"{arg}\") to parameter " +
$"{parameterName} of type {targetType.FullName}: {e}", e);
}
};
}
Expand Down
2 changes: 2 additions & 0 deletions Runtime/IActionRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ public static class ActionRegistrationExtension {
// GYB11 END


/// <inheritdoc cref="AddCommandHandler(string, Delegate)"/>
public static void AddCommandHandler(this IActionRegistration registration, string commandName, System.Func<Coroutine> handler) => registration.AddCommandHandler(commandName, (Delegate)handler);
/// <inheritdoc cref="AddCommandHandler(string, Delegate)"/>
public static void AddCommandHandler<T1>(this IActionRegistration registration, string commandName, System.Func<T1, Coroutine> handler) => registration.AddCommandHandler(commandName, (Delegate)handler);
/// <inheritdoc cref="AddCommandHandler(string, Delegate)"/>
public static void AddCommandHandler<T1, T2>(this IActionRegistration registration, string commandName, System.Func<T1, T2, Coroutine> handler) => registration.AddCommandHandler(commandName, (Delegate)handler);
Expand Down
10 changes: 10 additions & 0 deletions Tests/Editor/Editor Test Resources/Scripts/TestActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,15 @@ public static int DemoFunction2(int input) {
Debug.Log($"Demo function {nameof(DemoFunction2)}");
return 1;
}

[YarnCommand("instance_variadic")]
public void VariadicInstanceFunction(int required, params bool[] bools) {
Debug.Log($"Variadic instance function: {required}, ({string.Join(", ", bools)})");
}

[YarnCommand("static_variadic")]
public void VariadicStaticFunction(int required, params bool[] bools) {
Debug.Log($"Variadic static function: {required}, ({string.Join(", ", bools)})");
}
}
}
2 changes: 2 additions & 0 deletions Tests/Runtime/CommandDispatchTests/CommandDispatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public void CommandDispatch_Passes() {
"static_demo_action",
"static_demo_action_with_params",
"static_demo_action_with_optional_params",
"static_variadic",
"instance_variadic",
};

var expectedFunctionNames = new[] {
Expand Down
10 changes: 10 additions & 0 deletions Tests/Runtime/DialogueRunnerTests/DialogueRunnerMockUI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ public static string TestFunctionVariable(string text)
return $"{text} no you're not! {text}";
}

[YarnCommand("testInstanceVariadic")]
public void VariadicInstanceFunction(int required, params bool[] bools) {
Debug.Log($"Variadic instance function: {required}, ({string.Join(", ", bools)})");
}
[YarnCommand("testStaticVariadic")]
public static void VariadicStaticFunction(int required, params bool[] bools) {
Debug.Log($"Variadic static function: {required}, ({string.Join(", ", bools)})");
}


public override async YarnTask RunLineAsync(LocalizedLine line, CancellationToken token)
{
// Store the localised text in our CurrentLine property
Expand Down
17 changes: 17 additions & 0 deletions Tests/Runtime/DialogueRunnerTests/DialogueRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,23 @@ public void HandleCommand_FailsWhenParameterTypesNotValid(string command, string
Assert.That(regex.IsMatch(result.Message));
}

[TestCase("testInstanceVariadic DialogueRunner 1", "Variadic instance function: 1, ()")]
[TestCase("testInstanceVariadic DialogueRunner 1 true", "Variadic instance function: 1, (True)")]
[TestCase("testInstanceVariadic DialogueRunner 1 true false", "Variadic instance function: 1, (True, False)")]
[TestCase("testStaticVariadic 1", "Variadic static function: 1, ()")]
[TestCase("testStaticVariadic 1 true", "Variadic static function: 1, (True)")]
[TestCase("testStaticVariadic 1 true false", "Variadic static function: 1, (True, False)")]
public void HandleCommand_DispatchesCommandsWithVariadicParameters(string command, string expectedLog)
{
var dispatcher = runner.CommandDispatcher;

LogAssert.Expect(LogType.Log, expectedLog);

var result = dispatcher.DispatchCommand(command, runner);

Assert.AreEqual(CommandDispatchResult.StatusType.Succeeded, result.Status);
}

[Test]
public void AddCommandHandler_RegistersCommands()
{
Expand Down

0 comments on commit 3adb295

Please sign in to comment.