From 95ad954fa4b761dd32eefa072622cb1168c4d028 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 15 Jan 2021 18:48:30 -0500 Subject: allow get/setting PerScreen values by screen ID --- src/SMAPI/Utilities/PerScreen.cs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) (limited to 'src/SMAPI/Utilities') diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 89d08e87..1498488b 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -49,20 +49,20 @@ namespace StardewModdingAPI.Utilities /// Get the value for a given screen ID, creating it if needed. /// The screen ID to check. - internal T GetValueForScreen(int screenId) + public T GetValueForScreen(int screenId) { - this.RemoveDeadPlayers(); + this.RemoveDeadScreens(); return this.States.TryGetValue(screenId, out T state) ? state : this.States[screenId] = this.CreateNewState(); } - /// Set the value for a given screen ID, creating it if needed. + /// Set the value for a given screen ID. /// The screen ID whose value set. /// The value to set. - internal void SetValueForScreen(int screenId, T value) + public void SetValueForScreen(int screenId, T value) { - this.RemoveDeadPlayers(); + this.RemoveDeadScreens(); this.States[screenId] = value; } @@ -70,18 +70,17 @@ namespace StardewModdingAPI.Utilities /********* ** Private methods *********/ - /// Remove players who are no longer have a split-screen index. - /// Returns whether any players were removed. - private void RemoveDeadPlayers() + /// Remove screens which are no longer active. + private void RemoveDeadScreens() { if (this.LastRemovedScreenId == Context.LastRemovedScreenId) return; - this.LastRemovedScreenId = Context.LastRemovedScreenId; - foreach (int id in this.States.Keys.ToArray()) + + foreach (var pair in this.States.ToArray()) { - if (!Context.HasScreenId(id)) - this.States.Remove(id); + if (!Context.HasScreenId(pair.Key)) + this.States.Remove(pair.Key); } } } -- cgit From a9b99c12069bfabca81a74c83eda7f1325c2522a Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 15 Jan 2021 18:48:31 -0500 Subject: allow resetting a PerScreen field --- docs/release-notes.md | 2 +- src/SMAPI/Utilities/PerScreen.cs | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) (limited to 'src/SMAPI/Utilities') diff --git a/docs/release-notes.md b/docs/release-notes.md index be34e653..deac0bc8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,7 +9,7 @@ ## Upcoming release * For modders: - * Expanded `PerScreen` API: you can now get/set the value for any screen. + * Expanded `PerScreen` API: you can now get/set the value for any screen, or clear all values. * Added an option to disable rewriting mods for compatibility (thanks to Bpendragon!). This may prevent older mods from loading, but bypasses a Visual Studio crash when debugging. * For the Error Handler mod: diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 1498488b..60406d6b 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -11,10 +11,10 @@ namespace StardewModdingAPI.Utilities /********* ** Fields *********/ - /// Create the initial value for a player. + /// Create the initial value for a screen. private readonly Func CreateNewState; - /// The tracked values for each player. + /// The tracked values for each screen. private readonly IDictionary States = new Dictionary(); /// The last value for which this instance was updated. @@ -24,8 +24,8 @@ namespace StardewModdingAPI.Utilities /********* ** Accessors *********/ - /// The value for the current player. - /// The value is initialized the first time it's requested for that player, unless it's set manually first. + /// The value for the current screen. + /// The value is initialized the first time it's requested for that screen, unless it's set manually first. public T Value { get => this.GetValueForScreen(Context.ScreenId); @@ -41,7 +41,7 @@ namespace StardewModdingAPI.Utilities : this(null) { } /// Construct an instance. - /// Create the initial state for a player screen. + /// Create the initial state for a screen. public PerScreen(Func createNewState) { this.CreateNewState = createNewState ?? (() => default); @@ -66,6 +66,12 @@ namespace StardewModdingAPI.Utilities this.States[screenId] = value; } + /// Remove all active values. + public void ResetAllScreens() + { + this.RemoveScreens(p => true); + } + /********* ** Private methods @@ -77,9 +83,16 @@ namespace StardewModdingAPI.Utilities return; this.LastRemovedScreenId = Context.LastRemovedScreenId; + this.RemoveScreens(id => !Context.HasScreenId(id)); + } + + /// Remove screens matching a condition. + /// Returns whether a screen ID should be removed. + private void RemoveScreens(Func shouldRemove) + { foreach (var pair in this.States.ToArray()) { - if (!Context.HasScreenId(pair.Key)) + if (shouldRemove(pair.Key)) this.States.Remove(pair.Key); } } -- cgit From 812251e7ae532d7a2f10d46ff366bf19e67e88d0 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 15 Jan 2021 18:48:31 -0500 Subject: allow getting all active values from a PerScreen field --- docs/release-notes.md | 2 +- src/SMAPI/Utilities/PerScreen.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) (limited to 'src/SMAPI/Utilities') diff --git a/docs/release-notes.md b/docs/release-notes.md index deac0bc8..c36d80ed 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,7 +9,7 @@ ## Upcoming release * For modders: - * Expanded `PerScreen` API: you can now get/set the value for any screen, or clear all values. + * Expanded `PerScreen` API: you can now get/set the value for any screen, get all active values, or clear all values. * Added an option to disable rewriting mods for compatibility (thanks to Bpendragon!). This may prevent older mods from loading, but bypasses a Visual Studio crash when debugging. * For the Error Handler mod: diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 60406d6b..20b8fbce 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -47,6 +47,13 @@ namespace StardewModdingAPI.Utilities this.CreateNewState = createNewState ?? (() => default); } + /// Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet. + public IEnumerable> GetActiveValues() + { + this.RemoveDeadScreens(); + return this.States.ToArray(); + } + /// Get the value for a given screen ID, creating it if needed. /// The screen ID to check. public T GetValueForScreen(int screenId) -- cgit From ff16a6567b6137b1aafed3470406d5f5884a5bdc Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 19 Jan 2021 21:20:25 -0500 Subject: add multi-key binding API (#744) --- docs/release-notes.md | 3 +- .../Converters/SemanticVersionConverter.cs | 4 +- src/SMAPI.sln.DotSettings | 2 + src/SMAPI/Framework/SCore.cs | 1 + src/SMAPI/Framework/SGame.cs | 13 ++ .../Framework/Serialization/KeybindConverter.cs | 89 ++++++++++++ src/SMAPI/Utilities/Keybind.cs | 131 ++++++++++++++++++ src/SMAPI/Utilities/KeybindList.cs | 152 +++++++++++++++++++++ 8 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 src/SMAPI/Framework/Serialization/KeybindConverter.cs create mode 100644 src/SMAPI/Utilities/Keybind.cs create mode 100644 src/SMAPI/Utilities/KeybindList.cs (limited to 'src/SMAPI/Utilities') diff --git a/docs/release-notes.md b/docs/release-notes.md index 8e31b79c..edf4481d 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -12,6 +12,7 @@ * Improved game path detection in the installer. The installer now prefers the path registered by Steam or GOG Galaxy, and can also now detect the default install path for manual GOG installs. * For modders: + * Added [API for multi-key bindings](https://stardewcommunitywiki.com/Modding:Modder_Guide/APIs/Input#KeybindList). * Improved multiplayer APIs: * `PerScreen` now lets you get/set the value for any screen, get all active values, or clear all values. * Peer data for the multiplayer API/events now includes `IsSplitScreen` and `ScreenID` fields. @@ -22,9 +23,9 @@ * Fixed quarry bridge not fixed if the mountain map was reloaded. * Added an option to disable rewriting mods for compatibility (thanks to Bpendragon!). This prevents older mods from loading but bypasses a Visual Studio debugger crash. * Game errors shown in the chatbox are now logged. + * Moved vanilla error-handling into a new Error Handler mod. This simplifies the core SMAPI logic, and lets users disable it if needed. * For the Error Handler mod: - * Added in SMAPI 3.9. This has vanilla error-handling that was previously added by SMAPI directly. That simplifies the core SMAPI logic, and lets players or modders disable it if needed. * Added a detailed message for the _Input string was not in a correct format_ error when the game fails to parse an item text description. * For the web UI: diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index 3604956b..cf69104d 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters ** Accessors *********/ /// Get whether this converter can read JSON. - public override bool CanRead => true; + public override bool CanRead { get; } = true; /// Get whether this converter can write JSON. - public override bool CanWrite => true; + public override bool CanWrite { get; } = true; /********* diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 76e863cc..29d4ade5 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -39,6 +39,8 @@ True True True + True + True True True True diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 1b39065f..0c55164c 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -208,6 +208,7 @@ namespace StardewModdingAPI.Framework { JsonConverter[] converters = { new ColorConverter(), + new KeybindConverter(), new PointConverter(), new Vector2Converter(), new RectangleConverter() diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 634680a0..af7fa387 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Locations; @@ -124,6 +125,18 @@ namespace StardewModdingAPI.Framework this.OnUpdating = onUpdating; } + /// Get the current input state for a button. + /// The button to check. + /// This is intended for use by and shouldn't be used directly in most cases. + internal static SButtonState GetInputState(SButton button) + { + SInputState input = Game1.input as SInputState; + if (input == null) + throw new InvalidOperationException("SMAPI's input state is not in a ready state yet."); + + return input.GetState(button); + } + /********* ** Protected methods diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs new file mode 100644 index 00000000..1bc146f8 --- /dev/null +++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs @@ -0,0 +1,89 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Framework.Serialization +{ + /// Handles deserialization of and models. + internal class KeybindConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// + public override bool CanRead { get; } = true; + + /// + public override bool CanWrite { get; } = true; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return + typeof(Keybind).IsAssignableFrom(objectType) + || typeof(KeybindList).IsAssignableFrom(objectType); + } + + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + + // validate JSON type + if (reader.TokenType != JsonToken.String) + throw new SParseException($"Can't parse {nameof(KeybindList)} from {reader.TokenType} node (path: {reader.Path})."); + + // parse raw value + string str = JToken.Load(reader).Value(); + if (objectType == typeof(Keybind)) + { + return Keybind.TryParse(str, out Keybind parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); + } + + if (objectType == typeof(KeybindList)) + { + return KeybindList.TryParse(str, out KeybindList parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); + } + + throw new SParseException($"Can't parse unexpected type {objectType} from {reader.TokenType} node (path: {reader.Path})."); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } + + + /********* + ** Private methods + *********/ + /// Read a JSON string. + /// The JSON string value. + /// The path to the current JSON node. + protected KeybindList ReadString(string str, string path) + { + return KeybindList.TryParse(str, out KeybindList parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); + } + } +} diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs new file mode 100644 index 00000000..9d6cd6ee --- /dev/null +++ b/src/SMAPI/Utilities/Keybind.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Utilities +{ + /// A single multi-key binding which can be triggered by the player. + /// NOTE: this is part of , and usually shouldn't be used directly. + public class Keybind + { + /********* + ** Accessors + *********/ + /// The buttons that must be down to activate the keybind. + public SButton[] Buttons { get; } + + /// Whether any keys are bound. + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The buttons that must be down to activate the keybind. + public Keybind(params SButton[] buttons) + { + this.Buttons = buttons; + this.IsBound = buttons.Any(p => p != SButton.None); + } + + /// Parse a keybind string, if it's valid. + /// The keybind string. See remarks on for format details. + /// The parsed keybind, if valid. + /// The parse errors, if any. + public static bool TryParse(string input, out Keybind parsed, out string[] errors) + { + // empty input + if (string.IsNullOrWhiteSpace(input)) + { + parsed = new Keybind(SButton.None); + errors = new string[0]; + return true; + } + + // parse buttons + string[] rawButtons = input.Split('+'); + SButton[] buttons = new SButton[rawButtons.Length]; + List rawErrors = new List(); + for (int i = 0; i < buttons.Length; i++) + { + string rawButton = rawButtons[i].Trim(); + if (string.IsNullOrWhiteSpace(rawButton)) + rawErrors.Add("Invalid empty button value"); + else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button)) + { + string error = $"Invalid button value '{rawButton}'"; + + switch (rawButton.ToLower()) + { + case "shift": + error += $" (did you mean {SButton.LeftShift}?)"; + break; + + case "ctrl": + case "control": + error += $" (did you mean {SButton.LeftControl}?)"; + break; + + case "alt": + error += $" (did you mean {SButton.LeftAlt}?)"; + break; + } + + rawErrors.Add(error); + } + else + buttons[i] = button; + } + + // build result + if (rawErrors.Any()) + { + parsed = null; + errors = rawErrors.ToArray(); + return false; + } + else + { + parsed = new Keybind(buttons); + errors = new string[0]; + return true; + } + } + + /// Get the keybind state relative to the previous tick. + public SButtonState GetState() + { + SButtonState[] states = this.Buttons.Select(SGame.GetInputState).Distinct().ToArray(); + + // single state + if (states.Length == 1) + return states[0]; + + // if any key has no state, the whole set wasn't enabled last tick + if (states.Contains(SButtonState.None)) + return SButtonState.None; + + // mix of held + pressed => pressed + if (states.All(p => p == SButtonState.Pressed || p == SButtonState.Held)) + return SButtonState.Pressed; + + // mix of held + released => released + if (states.All(p => p == SButtonState.Held || p == SButtonState.Released)) + return SButtonState.Released; + + // not down last tick or now + return SButtonState.None; + } + + /// Get a string representation of the keybind. + /// A keybind is serialized to a string like LeftControl + S, where each key is separated with +. The key order is commutative, so LeftControl + S and S + LeftControl are identical. + public override string ToString() + { + return this.Buttons.Length > 0 + ? string.Join(" + ", this.Buttons) + : SButton.None.ToString(); + } + } +} diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs new file mode 100644 index 00000000..f6933af3 --- /dev/null +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Serialization; + +namespace StardewModdingAPI.Utilities +{ + /// A set of multi-key bindings which can be triggered by the player. + public class KeybindList + { + /********* + ** Accessors + *********/ + /// The individual keybinds. + public Keybind[] Keybinds { get; } + + /// Whether any keys are bound. + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying keybinds. + /// See or to parse it from a string representation. You can also use this type directly in your config or JSON data models, and it'll be parsed by SMAPI. + public KeybindList(params Keybind[] keybinds) + { + this.Keybinds = keybinds.Where(p => p.IsBound).ToArray(); + this.IsBound = this.Keybinds.Any(); + } + + /// Parse a keybind list from a string, and throw an exception if it's not valid. + /// The keybind string. See remarks on for format details. + /// The format is invalid. + public static KeybindList Parse(string input) + { + return KeybindList.TryParse(input, out KeybindList parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{input}'.\n{string.Join("\n", errors)}"); + } + + /// Try to parse a keybind list from a string. + /// The keybind string. See remarks on for format details. + /// The parsed keybind list, if valid. + /// The errors that occurred while parsing the input, if any. + public static bool TryParse(string input, out KeybindList parsed, out string[] errors) + { + // empty input + if (string.IsNullOrWhiteSpace(input)) + { + parsed = new KeybindList(); + errors = new string[0]; + return true; + } + + // parse buttons + var rawErrors = new List(); + var keybinds = new List(); + foreach (string rawSet in input.Split(',')) + { + if (string.IsNullOrWhiteSpace(rawSet)) + continue; + + if (!Keybind.TryParse(rawSet, out Keybind keybind, out string[] curErrors)) + rawErrors.AddRange(curErrors); + else + keybinds.Add(keybind); + } + + // build result + if (rawErrors.Any()) + { + parsed = null; + errors = rawErrors.ToArray(); + return false; + } + else + { + parsed = new KeybindList(keybinds.ToArray()); + errors = new string[0]; + return true; + } + } + + /// Get the overall keybind list state relative to the previous tick. + /// States are transitive across keybind. For example, if one keybind is 'released' and another is 'pressed', the state of the keybind list is 'held'. + public SButtonState GetState() + { + bool wasPressed = false; + bool isPressed = false; + + foreach (Keybind keybind in this.Keybinds) + { + switch (keybind.GetState()) + { + case SButtonState.Pressed: + isPressed = true; + break; + + case SButtonState.Held: + wasPressed = true; + isPressed = true; + break; + + case SButtonState.Released: + wasPressed = true; + break; + } + } + + if (wasPressed == isPressed) + { + return wasPressed + ? SButtonState.Held + : SButtonState.None; + } + + return wasPressed + ? SButtonState.Released + : SButtonState.Pressed; + } + + /// Get whether any of the button sets are pressed. + public bool IsDown() + { + SButtonState state = this.GetState(); + return state == SButtonState.Pressed || state == SButtonState.Held; + } + + /// Get whether the input binding was just pressed this tick. + public bool JustPressed() + { + return this.GetState() == SButtonState.Pressed; + } + + /// Get the keybind which is currently down, if any. If there are multiple keybinds down, the first one is returned. + public Keybind GetKeybindCurrentlyDown() + { + return this.Keybinds.FirstOrDefault(p => p.GetState().IsDown()); + } + + /// Get a string representation of the input binding. + /// A keybind list is serialized to a string like LeftControl + S, LeftAlt + S, where each multi-key binding is separated with , and the keys within each keybind are separated with +. The key order is commutative, so LeftControl + S and S + LeftControl are identical. + public override string ToString() + { + return this.Keybinds.Length > 0 + ? string.Join(", ", this.Keybinds.Select(p => p.ToString())) + : SButton.None.ToString(); + } + } +} -- cgit From 7e90b1c60aa12d8c552a56738711500cab783be0 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 19 Jan 2021 21:47:31 -0500 Subject: add shortcut method to create a keybind list for a single default keybind (#744) --- src/SMAPI/Utilities/KeybindList.cs | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'src/SMAPI/Utilities') diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs index f6933af3..4ae66ab7 100644 --- a/src/SMAPI/Utilities/KeybindList.cs +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -83,6 +83,15 @@ namespace StardewModdingAPI.Utilities } } + /// Get a keybind list for a single keybind. + /// The buttons that must be down to activate the keybind. + public static KeybindList ForSingle(params SButton[] buttons) + { + return new KeybindList( + new Keybind(buttons) + ); + } + /// Get the overall keybind list state relative to the previous tick. /// States are transitive across keybind. For example, if one keybind is 'released' and another is 'pressed', the state of the keybind list is 'held'. public SButtonState GetState() -- cgit From 587d60495e01b1bdacc31acc7e15a87d7f441839 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 20 Jan 2021 01:02:49 -0500 Subject: add unit tests for KeybindList (#744) --- src/SMAPI.Tests/Utilities/KeybindListTests.cs | 152 ++++++++++++++++++++++++++ src/SMAPI/Utilities/Keybind.cs | 10 +- src/SMAPI/Utilities/KeybindList.cs | 2 +- 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/SMAPI.Tests/Utilities/KeybindListTests.cs (limited to 'src/SMAPI/Utilities') diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs new file mode 100644 index 00000000..0bd6ec17 --- /dev/null +++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; + +namespace SMAPI.Tests.Utilities +{ + /// Unit tests for . + [TestFixture] + internal class KeybindListTests + { + /********* + ** Unit tests + *********/ + /**** + ** TryParse + ****/ + /// Assert the parsed fields when constructed from a simple single-key string. + [TestCaseSource(nameof(KeybindListTests.GetAllButtons))] + public void TryParse_SimpleValue(SButton button) + { + // act + bool success = KeybindList.TryParse($"{button}", out KeybindList parsed, out string[] errors); + + // assert + Assert.IsTrue(success, "Parsing unexpectedly failed."); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.AreEqual(parsed.ToString(), $"{button}"); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + } + + /// Assert the parsed fields when constructed from multi-key values. + [TestCase("", ExpectedResult = "None")] + [TestCase(" ", ExpectedResult = "None")] + [TestCase(null, ExpectedResult = "None")] + [TestCase("A + B", ExpectedResult = "A + B")] + [TestCase("A+B", ExpectedResult = "A + B")] + [TestCase(" A+ B ", ExpectedResult = "A + B")] + [TestCase("a +b", ExpectedResult = "A + B")] + [TestCase("a +b, LEFTcontrol + leftALT + LeftSHifT + delete", ExpectedResult = "A + B, LeftControl + LeftAlt + LeftShift + Delete")] + + [TestCase(",", ExpectedResult = "None")] + [TestCase("A,", ExpectedResult = "A")] + [TestCase(",A", ExpectedResult = "A")] + public string TryParse_MultiValues(string input) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + + // assert + Assert.IsTrue(success, "Parsing unexpectedly failed."); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + return parsed.ToString(); + } + + /// Assert invalid values are rejected. + [TestCase("+", "Invalid empty button value")] + [TestCase("A+", "Invalid empty button value")] + [TestCase("+C", "Invalid empty button value")] + [TestCase("A + B +, C", "Invalid empty button value")] + [TestCase("A, TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + [TestCase("A + TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + public void TryParse_InvalidValues(string input, string expectedError) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + + // assert + Assert.IsFalse(success, "Parsing unexpectedly succeeded."); + Assert.IsNull(parsed, "The parsed result should be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.AreEqual(expectedError, string.Join("; ", errors), "The errors don't match the expected ones."); + } + + + /**** + ** GetState + ****/ + /// Assert that returns the expected result for a given input state. + // single value + [TestCase("A", "A:Held", ExpectedResult = SButtonState.Held)] + [TestCase("A", "A:Pressed", ExpectedResult = SButtonState.Pressed)] + [TestCase("A", "A:Released", ExpectedResult = SButtonState.Released)] + [TestCase("A", "A:None", ExpectedResult = SButtonState.None)] + + // multiple values + [TestCase("A + B + C, D", "A:Released, B:None, C:None, D:Pressed", ExpectedResult = SButtonState.Pressed)] // right pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Held, C:Pressed, D:None", ExpectedResult = SButtonState.Pressed)] // left pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Pressed, C:Released, D:None", ExpectedResult = SButtonState.None)] // one key released but other keys weren't down last tick => none + [TestCase("A + B + C, D", "A:Held, B:Held, C:Released, D:None", ExpectedResult = SButtonState.Released)] // all three keys were down last tick and now one is released => released + + // transitive + [TestCase("A, B", "A: Released, B: Pressed", ExpectedResult = SButtonState.Held)] + public SButtonState GetState(string input, string stateMap) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + if (success && parsed?.Keybinds != null) + { + foreach (var keybind in parsed.Keybinds) +#pragma warning disable 618 // method is marked obsolete because it should only be used in unit tests + keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap); +#pragma warning restore 618 + } + + // assert + Assert.IsTrue(success, "Parsing unexpected failed"); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + return parsed.GetState(); + } + + + /********* + ** Private methods + *********/ + /// Get all defined buttons. + private static IEnumerable GetAllButtons() + { + foreach (SButton button in Enum.GetValues(typeof(SButton))) + yield return button; + } + + /// Get the button state defined by a mapping string. + /// The button to check. + /// The state map. + private SButtonState GetStateFromMap(SButton button, string stateMap) + { + foreach (string rawPair in stateMap.Split(',')) + { + // parse values + string[] parts = rawPair.Split(new[] { ':' }, 2); + if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton)) + Assert.Fail($"The state map is invalid: unknown button value '{parts[0].Trim()}'"); + if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state)) + Assert.Fail($"The state map is invalid: unknown state value '{parts[1].Trim()}'"); + + // get state + if (curButton == button) + return state; + } + + Assert.Fail($"The state map doesn't define button value '{button}'."); + return SButtonState.None; + } + } +} diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs index 9d6cd6ee..dd8d2861 100644 --- a/src/SMAPI/Utilities/Keybind.cs +++ b/src/SMAPI/Utilities/Keybind.cs @@ -9,6 +9,14 @@ namespace StardewModdingAPI.Utilities /// NOTE: this is part of , and usually shouldn't be used directly. public class Keybind { + /********* + ** Fields + *********/ + /// Get the current input state for a button. + [Obsolete("This property should only be used for unit tests.")] + internal Func GetButtonState { get; set; } = SGame.GetInputState; + + /********* ** Accessors *********/ @@ -97,7 +105,7 @@ namespace StardewModdingAPI.Utilities /// Get the keybind state relative to the previous tick. public SButtonState GetState() { - SButtonState[] states = this.Buttons.Select(SGame.GetInputState).Distinct().ToArray(); + SButtonState[] states = this.Buttons.Select(this.GetButtonState).Distinct().ToArray(); // single state if (states.Length == 1) diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs index 4ae66ab7..1845285a 100644 --- a/src/SMAPI/Utilities/KeybindList.cs +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Utilities if (rawErrors.Any()) { parsed = null; - errors = rawErrors.ToArray(); + errors = rawErrors.Distinct().ToArray(); return false; } else -- cgit