diff options
Diffstat (limited to 'src/SMAPI/Utilities')
-rw-r--r-- | src/SMAPI/Utilities/Keybind.cs | 139 | ||||
-rw-r--r-- | src/SMAPI/Utilities/KeybindList.cs | 161 | ||||
-rw-r--r-- | src/SMAPI/Utilities/PerScreen.cs | 53 |
3 files changed, 336 insertions, 17 deletions
diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs new file mode 100644 index 00000000..dd8d2861 --- /dev/null +++ b/src/SMAPI/Utilities/Keybind.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A single multi-key binding which can be triggered by the player.</summary> + /// <remarks>NOTE: this is part of <see cref="KeybindList"/>, and usually shouldn't be used directly.</remarks> + public class Keybind + { + /********* + ** Fields + *********/ + /// <summary>Get the current input state for a button.</summary> + [Obsolete("This property should only be used for unit tests.")] + internal Func<SButton, SButtonState> GetButtonState { get; set; } = SGame.GetInputState; + + + /********* + ** Accessors + *********/ + /// <summary>The buttons that must be down to activate the keybind.</summary> + public SButton[] Buttons { get; } + + /// <summary>Whether any keys are bound.</summary> + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="buttons">The buttons that must be down to activate the keybind.</param> + public Keybind(params SButton[] buttons) + { + this.Buttons = buttons; + this.IsBound = buttons.Any(p => p != SButton.None); + } + + /// <summary>Parse a keybind string, if it's valid.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <param name="parsed">The parsed keybind, if valid.</param> + /// <param name="errors">The parse errors, if any.</param> + 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<string> rawErrors = new List<string>(); + 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; + } + } + + /// <summary>Get the keybind state relative to the previous tick.</summary> + public SButtonState GetState() + { + SButtonState[] states = this.Buttons.Select(this.GetButtonState).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; + } + + /// <summary>Get a string representation of the keybind.</summary> + /// <remarks>A keybind is serialized to a string like <c>LeftControl + S</c>, where each key is separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks> + 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..1845285a --- /dev/null +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Serialization; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A set of multi-key bindings which can be triggered by the player.</summary> + public class KeybindList + { + /********* + ** Accessors + *********/ + /// <summary>The individual keybinds.</summary> + public Keybind[] Keybinds { get; } + + /// <summary>Whether any keys are bound.</summary> + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="keybinds">The underlying keybinds.</param> + /// <remarks>See <see cref="Parse"/> or <see cref="TryParse"/> 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.</remarks> + public KeybindList(params Keybind[] keybinds) + { + this.Keybinds = keybinds.Where(p => p.IsBound).ToArray(); + this.IsBound = this.Keybinds.Any(); + } + + /// <summary>Parse a keybind list from a string, and throw an exception if it's not valid.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <exception cref="FormatException">The <paramref name="input"/> format is invalid.</exception> + 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)}"); + } + + /// <summary>Try to parse a keybind list from a string.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <param name="parsed">The parsed keybind list, if valid.</param> + /// <param name="errors">The errors that occurred while parsing the input, if any.</param> + 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<string>(); + var keybinds = new List<Keybind>(); + 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.Distinct().ToArray(); + return false; + } + else + { + parsed = new KeybindList(keybinds.ToArray()); + errors = new string[0]; + return true; + } + } + + /// <summary>Get a keybind list for a single keybind.</summary> + /// <param name="buttons">The buttons that must be down to activate the keybind.</param> + public static KeybindList ForSingle(params SButton[] buttons) + { + return new KeybindList( + new Keybind(buttons) + ); + } + + /// <summary>Get the overall keybind list state relative to the previous tick.</summary> + /// <remarks>States are transitive across keybind. For example, if one keybind is 'released' and another is 'pressed', the state of the keybind list is 'held'.</remarks> + 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; + } + + /// <summary>Get whether any of the button sets are pressed.</summary> + public bool IsDown() + { + SButtonState state = this.GetState(); + return state == SButtonState.Pressed || state == SButtonState.Held; + } + + /// <summary>Get whether the input binding was just pressed this tick.</summary> + public bool JustPressed() + { + return this.GetState() == SButtonState.Pressed; + } + + /// <summary>Get the keybind which is currently down, if any. If there are multiple keybinds down, the first one is returned.</summary> + public Keybind GetKeybindCurrentlyDown() + { + return this.Keybinds.FirstOrDefault(p => p.GetState().IsDown()); + } + + /// <summary>Get a string representation of the input binding.</summary> + /// <remarks>A keybind list is serialized to a string like <c>LeftControl + S, LeftAlt + S</c>, where each multi-key binding is separated with <c>,</c> and the keys within each keybind are separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks> + public override string ToString() + { + return this.Keybinds.Length > 0 + ? string.Join(", ", this.Keybinds.Select(p => p.ToString())) + : SButton.None.ToString(); + } + } +} diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 89d08e87..20b8fbce 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -11,10 +11,10 @@ namespace StardewModdingAPI.Utilities /********* ** Fields *********/ - /// <summary>Create the initial value for a player.</summary> + /// <summary>Create the initial value for a screen.</summary> private readonly Func<T> CreateNewState; - /// <summary>The tracked values for each player.</summary> + /// <summary>The tracked values for each screen.</summary> private readonly IDictionary<int, T> States = new Dictionary<int, T>(); /// <summary>The last <see cref="Context.LastRemovedScreenId"/> value for which this instance was updated.</summary> @@ -24,8 +24,8 @@ namespace StardewModdingAPI.Utilities /********* ** Accessors *********/ - /// <summary>The value for the current player.</summary> - /// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks> + /// <summary>The value for the current screen.</summary> + /// <remarks>The value is initialized the first time it's requested for that screen, unless it's set manually first.</remarks> public T Value { get => this.GetValueForScreen(Context.ScreenId); @@ -41,47 +41,66 @@ namespace StardewModdingAPI.Utilities : this(null) { } /// <summary>Construct an instance.</summary> - /// <param name="createNewState">Create the initial state for a player screen.</param> + /// <param name="createNewState">Create the initial state for a screen.</param> public PerScreen(Func<T> createNewState) { this.CreateNewState = createNewState ?? (() => default); } + /// <summary>Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet.</summary> + public IEnumerable<KeyValuePair<int, T>> GetActiveValues() + { + this.RemoveDeadScreens(); + return this.States.ToArray(); + } + /// <summary>Get the value for a given screen ID, creating it if needed.</summary> /// <param name="screenId">The screen ID to check.</param> - 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(); } - /// <summary>Set the value for a given screen ID, creating it if needed.</summary> + /// <summary>Set the value for a given screen ID.</summary> /// <param name="screenId">The screen ID whose value set.</param> /// <param name="value">The value to set.</param> - internal void SetValueForScreen(int screenId, T value) + public void SetValueForScreen(int screenId, T value) { - this.RemoveDeadPlayers(); + this.RemoveDeadScreens(); this.States[screenId] = value; } + /// <summary>Remove all active values.</summary> + public void ResetAllScreens() + { + this.RemoveScreens(p => true); + } + /********* ** Private methods *********/ - /// <summary>Remove players who are no longer have a split-screen index.</summary> - /// <returns>Returns whether any players were removed.</returns> - private void RemoveDeadPlayers() + /// <summary>Remove screens which are no longer active.</summary> + private void RemoveDeadScreens() { if (this.LastRemovedScreenId == Context.LastRemovedScreenId) return; - this.LastRemovedScreenId = Context.LastRemovedScreenId; - foreach (int id in this.States.Keys.ToArray()) + + this.RemoveScreens(id => !Context.HasScreenId(id)); + } + + /// <summary>Remove screens matching a condition.</summary> + /// <param name="shouldRemove">Returns whether a screen ID should be removed.</param> + private void RemoveScreens(Func<int, bool> shouldRemove) + { + foreach (var pair in this.States.ToArray()) { - if (!Context.HasScreenId(id)) - this.States.Remove(id); + if (shouldRemove(pair.Key)) + this.States.Remove(pair.Key); } } } |