using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input.Touch; using MLEM.Misc; namespace MLEM.Input { /// /// An input handler is a more advanced wrapper around MonoGame's default input system. /// It includes keyboard, mouse, gamepad and touch states, as well as a new "pressed" state for keys and the ability for keyboard and gamepad repeat events. /// public class InputHandler : GameComponent { /// /// Contains all of the gestures that have finished during the last update call. /// To easily query these gestures, use or . /// public readonly ReadOnlyCollection Gestures; /// /// Set this field to false to disable keyboard handling for this input handler. /// public bool HandleKeyboard; /// /// Set this field to false to disable mouse handling for this input handler. /// public bool HandleMouse; /// /// Set this field to false to disable keyboard handling for this input handler. /// public bool HandleGamepads; /// /// Set this field to false to disable touch handling for this input handler. /// public bool HandleTouch; /// /// This is the amount of time that has to pass before the first keyboard repeat event is triggered. /// /// public TimeSpan KeyRepeatDelay = TimeSpan.FromSeconds(0.65); /// /// This is the amount of time that has to pass between keyboard repeat events. /// /// public TimeSpan KeyRepeatRate = TimeSpan.FromSeconds(0.05); /// /// Set this field to false to disable keyboard repeat event handling. /// public bool HandleKeyboardRepeats = true; /// /// Set this field to false to disable gamepad repeat event handling. /// public bool HandleGamepadRepeats = true; /// /// This field represents the deadzone that gamepad have when input is queried for them using this input handler. /// A deadzone is the percentage (between 0 and 1) that an analog value has to exceed for it to be considered down () or pressed (). /// Querying of analog values is done using . /// public float GamepadButtonDeadzone; /// /// An array of all , and values that are currently down. /// Additionally, or can be used to determine the amount of time that a given input has been down for. /// public GenericInput[] InputsDown { get; private set; } = Array.Empty(); /// /// An array of all , and that are currently considered pressed. /// An input is considered pressed if it was up in the last update, and is up in the current one. /// public GenericInput[] InputsPressed { get; private set; } = Array.Empty(); /// /// Contains the touch state from the last update call /// public TouchCollection LastTouchState { get; private set; } /// /// Contains the current touch state /// public TouchCollection TouchState { get; private set; } /// /// Contains the , but with the taken into account. /// public IList LastViewportTouchState { get; private set; } /// /// Contains the , but with the taken into account. /// public IList ViewportTouchState { get; private set; } /// /// Contains the amount of gamepads that are currently connected. /// This field is automatically updated in /// public int ConnectedGamepads { get; private set; } /// /// Contains the mouse state from the last update call /// public MouseState LastMouseState { get; private set; } /// /// Contains the current mouse state /// public MouseState MouseState { get; private set; } /// /// Contains the position of the mouse from the last update call, extracted from /// public Point LastMousePosition => this.LastMouseState.Position; /// /// Contains the , but with the taken into account. /// public Point LastViewportMousePosition => this.LastMousePosition + this.ViewportOffset; /// /// Contains the current position of the mouse, extracted from /// public Point MousePosition => this.MouseState.Position; /// /// Contains the , but with the taken into account. /// public Point ViewportMousePosition => this.MousePosition + this.ViewportOffset; /// /// Contains the current scroll wheel value, in increments of 120 /// public int ScrollWheel => this.MouseState.ScrollWheelValue; /// /// Contains the scroll wheel value from the last update call, in increments of 120 /// public int LastScrollWheel => this.LastMouseState.ScrollWheelValue; /// /// Contains the keyboard state from the last update call /// public KeyboardState LastKeyboardState { get; private set; } /// /// Contains the current keyboard state /// public KeyboardState KeyboardState { get; private set; } private readonly GamePadState[] lastGamepads = new GamePadState[GamePad.MaximumGamePadCount]; private readonly GamePadState[] gamepads = new GamePadState[GamePad.MaximumGamePadCount]; private readonly DateTime[] lastGamepadButtonRepeats = new DateTime[GamePad.MaximumGamePadCount]; private readonly bool[] triggerGamepadButtonRepeat = new bool[GamePad.MaximumGamePadCount]; private readonly Buttons?[] heldGamepadButtons = new Buttons?[GamePad.MaximumGamePadCount]; private readonly List gestures = new List(); private readonly HashSet<(GenericInput, int)> consumedPresses = new HashSet<(GenericInput, int)>(); private Point ViewportOffset => new Point(-this.Game.GraphicsDevice.Viewport.X, -this.Game.GraphicsDevice.Viewport.Y); private Dictionary<(GenericInput, int), DateTime> inputsDownAccum = new Dictionary<(GenericInput, int), DateTime>(); private Dictionary<(GenericInput, int), DateTime> inputsDown = new Dictionary<(GenericInput, int), DateTime>(); private DateTime lastKeyRepeat; private bool triggerKeyRepeat; private Keys heldKey; /// /// Creates a new input handler with optional initial values. /// /// The game instance that this input handler belongs to /// If keyboard input should be handled /// If mouse input should be handled /// If gamepad input should be handled /// If touch input should be handled public InputHandler(Game game, bool handleKeyboard = true, bool handleMouse = true, bool handleGamepads = true, bool handleTouch = true) : base(game) { this.HandleKeyboard = handleKeyboard; this.HandleMouse = handleMouse; this.HandleGamepads = handleGamepads; this.HandleTouch = handleTouch; this.Gestures = this.gestures.AsReadOnly(); } /// /// Updates this input handler, querying pressed and released keys and calculating repeat events. /// Call this in your method. /// public void Update() { var now = DateTime.UtcNow; var active = this.Game.IsActive; this.consumedPresses.Clear(); if (this.HandleKeyboard) { this.LastKeyboardState = this.KeyboardState; this.KeyboardState = active ? Keyboard.GetState() : default; var pressedKeys = this.KeyboardState.GetPressedKeys(); foreach (var pressed in pressedKeys) this.AccumulateDown(pressed, -1); if (this.HandleKeyboardRepeats) { this.triggerKeyRepeat = false; // the key that started being held most recently should be the one being repeated this.heldKey = pressedKeys.OrderBy(k => this.GetDownTime(k)).FirstOrDefault(); if (this.TryGetDownTime(this.heldKey, out var heldTime)) { // if we've been holding the key longer than the initial delay... if (heldTime >= this.KeyRepeatDelay) { var diff = now - this.lastKeyRepeat; // and we've been holding it for longer than a repeat... if (diff >= this.KeyRepeatRate) { this.lastKeyRepeat = now; // then trigger a repeat, causing IsKeyPressed to be true once this.triggerKeyRepeat = true; } } } } } if (this.HandleMouse) { this.LastMouseState = this.MouseState; var state = Mouse.GetState(); if (active && this.Game.GraphicsDevice.Viewport.Bounds.Contains(state.Position)) { this.MouseState = state; foreach (var button in MouseExtensions.MouseButtons) { if (state.GetState(button) == ButtonState.Pressed) this.AccumulateDown(button, -1); } } else { // mouse position and scroll wheel value should be preserved when the mouse is out of bounds this.MouseState = new MouseState(state.X, state.Y, state.ScrollWheelValue, 0, 0, 0, 0, 0, state.HorizontalScrollWheelValue); } } if (this.HandleGamepads) { this.ConnectedGamepads = GamePad.MaximumGamePadCount; for (var i = 0; i < GamePad.MaximumGamePadCount; i++) { this.lastGamepads[i] = this.gamepads[i]; this.gamepads[i] = GamePadState.Default; if (GamePad.GetCapabilities(i).IsConnected) { if (active) { this.gamepads[i] = GamePad.GetState(i); foreach (var button in EnumHelper.Buttons) { if (this.IsGamepadButtonDown(button, i)) this.AccumulateDown(button, i); } } } else if (this.ConnectedGamepads > i) { this.ConnectedGamepads = i; } } if (this.HandleGamepadRepeats) { for (var i = 0; i < this.ConnectedGamepads; i++) { this.triggerGamepadButtonRepeat[i] = false; this.heldGamepadButtons[i] = EnumHelper.Buttons .Where(b => this.IsGamepadButtonDown(b, i)) .OrderBy(b => this.GetDownTime(b, i)) .Cast().FirstOrDefault(); if (this.heldGamepadButtons[i].HasValue && this.TryGetDownTime(this.heldGamepadButtons[i].Value, out var heldTime, i)) { if (heldTime >= this.KeyRepeatDelay) { var diff = now - this.lastGamepadButtonRepeats[i]; if (diff >= this.KeyRepeatRate) { this.lastGamepadButtonRepeats[i] = now; this.triggerGamepadButtonRepeat[i] = true; } } } } } } if (this.HandleTouch) { this.LastTouchState = this.TouchState; this.LastViewportTouchState = this.ViewportTouchState; this.TouchState = active ? TouchPanel.GetState() : default; if (this.TouchState.Count > 0 && this.ViewportOffset != Point.Zero) { this.ViewportTouchState = new List(); foreach (var touch in this.TouchState) { touch.TryGetPreviousLocation(out var previous); this.ViewportTouchState.Add(new TouchLocation(touch.Id, touch.State, touch.Position + this.ViewportOffset.ToVector2(), previous.State, previous.Position + this.ViewportOffset.ToVector2())); } } else { this.ViewportTouchState = this.TouchState; } this.gestures.Clear(); while (active && TouchPanel.IsGestureAvailable) this.gestures.Add(TouchPanel.ReadGesture()); } if (this.inputsDownAccum.Count <= 0) { this.InputsPressed = Array.Empty(); this.InputsDown = Array.Empty(); this.inputsDown.Clear(); } else { this.InputsPressed = this.inputsDownAccum.Keys.Where(kv => this.IsPressed(kv.Item1, kv.Item2)).Select(kv => kv.Item1).ToArray(); this.InputsDown = this.inputsDownAccum.Keys.Select(kv => kv.Item1).ToArray(); // swapping these collections means that we don't have to keep moving entries between them (this.inputsDown, this.inputsDownAccum) = (this.inputsDownAccum, this.inputsDown); this.inputsDownAccum.Clear(); } } /// public override void Update(GameTime gameTime) { this.Update(); } /// /// Returns the state of the indexth gamepad from the last update call /// /// The zero-based gamepad index /// The state of the gamepad last update public GamePadState GetLastGamepadState(int index) { return this.lastGamepads[index]; } /// /// Returns the current state of the indexth gamepad /// /// The zero-based gamepad index /// The current state of the gamepad public GamePadState GetGamepadState(int index) { return this.gamepads[index]; } /// public bool IsKeyDown(Keys key) { return this.KeyboardState.IsKeyDown(key); } /// public bool IsKeyUp(Keys key) { return this.KeyboardState.IsKeyUp(key); } /// public bool WasKeyDown(Keys key) { return this.LastKeyboardState.IsKeyDown(key); } /// public bool WasKeyUp(Keys key) { return this.LastKeyboardState.IsKeyUp(key); } /// /// Returns whether the given key is considered pressed. /// A key is considered pressed if it was not down the last update call, but is down the current update call. /// If is true, this method will also return true to signify a key repeat. /// /// The key to query /// If the key is pressed public bool IsKeyPressed(Keys key) { // if the queried key is the held key and a repeat should be triggered, return true if (this.HandleKeyboardRepeats && key == this.heldKey && this.triggerKeyRepeat) return true; return this.IsKeyPressedIgnoreRepeats(key); } /// /// Returns whether the given key is considered pressed. /// This has the same behavior as , but ignores keyboard repeat events. /// If is false, this method does the same as . /// /// The key to query /// If the key is pressed public bool IsKeyPressedIgnoreRepeats(Keys key) { return this.WasKeyUp(key) && this.IsKeyDown(key); } /// /// Returns whether the given key is considered pressed, and marks the press as consumed if it is. /// A key is considered pressed if it was not down the last update call, but is down the current update call. /// A key press is considered consumed if this method has already returned true previously since the last call. /// If is true, this method will also return true to signify a key repeat. /// /// The key to query. /// If the key is pressed and the press is not consumed yet. public bool TryConsumeKeyPressed(Keys key) { if (this.IsKeyPressed(key) && !this.consumedPresses.Contains((key, -1))) { this.consumedPresses.Add((key, -1)); return true; } return false; } /// /// Returns whether the given modifier key is down. /// /// The modifier key /// If the modifier key is down public bool IsModifierKeyDown(ModifierKey modifier) { foreach (var key in modifier.GetKeys()) { if (this.IsKeyDown(key)) return true; } return false; } /// /// Returns whether the given mouse button is currently down. /// /// The button to query /// Whether or not the queried button is down public bool IsMouseButtonDown(MouseButton button) { return this.MouseState.GetState(button) == ButtonState.Pressed; } /// /// Returns whether the given mouse button is currently up. /// /// The button to query /// Whether or not the queried button is up public bool IsMouseButtonUp(MouseButton button) { return this.MouseState.GetState(button) == ButtonState.Released; } /// /// Returns whether the given mouse button was down the last update call. /// /// The button to query /// Whether or not the queried button was down public bool WasMouseButtonDown(MouseButton button) { return this.LastMouseState.GetState(button) == ButtonState.Pressed; } /// /// Returns whether the given mouse button was up the last update call. /// /// The button to query /// Whether or not the queried button was up public bool WasMouseButtonUp(MouseButton button) { return this.LastMouseState.GetState(button) == ButtonState.Released; } /// /// Returns whether the given mouse button is considered pressed. /// A mouse button is considered pressed if it was up the last update call, and is down the current update call. /// /// The button to query /// Whether the button is pressed public bool IsMouseButtonPressed(MouseButton button) { return this.WasMouseButtonUp(button) && this.IsMouseButtonDown(button); } /// /// Returns whether the given mouse button is considered pressed, and marks the press as consumed if it is. /// A mouse button is considered pressed if it was up the last update call, and is down the current update call. /// A mouse button press is considered consumed if this method has already returned true previously since the last call. /// /// The button to query. /// If the button is pressed and the press is not consumed yet. public bool TryConsumeMouseButtonPressed(MouseButton button) { if (this.IsMouseButtonPressed(button) && !this.consumedPresses.Contains((button, -1))) { this.consumedPresses.Add((button, -1)); return true; } return false; } /// public bool IsGamepadButtonDown(Buttons button, int index = -1) { if (index < 0) { for (var i = 0; i < this.ConnectedGamepads; i++) { if (this.GetGamepadState(i).GetAnalogValue(button) > this.GamepadButtonDeadzone) return true; } return false; } return this.GetGamepadState(index).GetAnalogValue(button) > this.GamepadButtonDeadzone; } /// public bool IsGamepadButtonUp(Buttons button, int index = -1) { if (index < 0) { for (var i = 0; i < this.ConnectedGamepads; i++) { if (this.GetGamepadState(i).GetAnalogValue(button) <= this.GamepadButtonDeadzone) return true; } return false; } return this.GetGamepadState(index).GetAnalogValue(button) <= this.GamepadButtonDeadzone; } /// public bool WasGamepadButtonDown(Buttons button, int index = -1) { if (index < 0) { for (var i = 0; i < this.ConnectedGamepads; i++) { if (this.GetLastGamepadState(i).GetAnalogValue(button) > this.GamepadButtonDeadzone) return true; } return false; } return this.GetLastGamepadState(index).GetAnalogValue(button) > this.GamepadButtonDeadzone; } /// public bool WasGamepadButtonUp(Buttons button, int index = -1) { if (index < 0) { for (var i = 0; i < this.ConnectedGamepads; i++) { if (this.GetLastGamepadState(i).GetAnalogValue(button) <= this.GamepadButtonDeadzone) return true; } return false; } return this.GetLastGamepadState(index).GetAnalogValue(button) <= this.GamepadButtonDeadzone; } /// /// Returns whether the given gamepad button on the given index is considered pressed. /// A gamepad button is considered pressed if it was down the last update call, and is up the current update call. /// If is true, this method will also return true to signify a gamepad button repeat. /// /// The button to query /// The zero-based index of the gamepad, or -1 for any gamepad /// Whether the given button is pressed public bool IsGamepadButtonPressed(Buttons button, int index = -1) { if (this.HandleGamepadRepeats) { if (index < 0) { for (var i = 0; i < this.ConnectedGamepads; i++) { if (this.heldGamepadButtons[i] == button && this.triggerGamepadButtonRepeat[i]) return true; } } else if (this.heldGamepadButtons[index] == button && this.triggerGamepadButtonRepeat[index]) { return true; } } return this.IsGamepadButtonPressedIgnoreRepeats(button, index); } /// /// Returns whether the given key is considered pressed. /// This has the same behavior as , but ignores gamepad repeat events. /// If is false, this method does the same as . /// /// The button to query /// The zero-based index of the gamepad, or -1 for any gamepad /// Whether the given button is pressed public bool IsGamepadButtonPressedIgnoreRepeats(Buttons button, int index = -1) { return this.WasGamepadButtonUp(button, index) && this.IsGamepadButtonDown(button, index); } /// /// Returns whether the given gamepad button on the given index is considered pressed, and marks the press as consumed if it is. /// A gamepad button is considered pressed if it was down the last update call, and is up the current update call. /// A gamepad button press is considered consumed if this method has already returned true previously since the last call. /// If is true, this method will also return true to signify a gamepad button repeat. /// /// The button to query. /// The zero-based index of the gamepad, or -1 for any gamepad. /// Whether the given button is pressed and the press is not consumed yet. public bool TryConsumeGamepadButtonPressed(Buttons button, int index = -1) { if (this.IsGamepadButtonPressed(button) && !this.consumedPresses.Contains((button, index))) { this.consumedPresses.Add((button, index)); return true; } return false; } /// /// Queries for a gesture of a given type that finished during the current update call. /// /// The type of gesture to query for /// The resulting gesture sample, or default if there isn't one /// True if a gesture of the type was found, otherwise false public bool GetGesture(GestureType type, out GestureSample sample) { foreach (var gesture in this.Gestures) { if (type.HasFlag(gesture.GestureType)) { sample = gesture; return true; } } sample = default; return false; } /// /// Queries for a gesture of the given type that finished during the current update call. /// Unlike , the return value of this method takes the into account. /// /// The type of gesture to query for /// The resulting gesture sample with the taken into account, or default if there isn't one /// True if a gesture of the type was found, otherwise false public bool GetViewportGesture(GestureType type, out GestureSample sample) { if (this.GetGesture(type, out var original)) { sample = new GestureSample(original.GestureType, original.Timestamp, original.Position + this.ViewportOffset.ToVector2(), original.Position2 + this.ViewportOffset.ToVector2(), original.Delta, original.Delta2); return true; } sample = default; return false; } /// /// Returns if a given control of any kind is down. /// This is a helper function that can be passed a , or . /// /// The control whose down state to query /// The index of the gamepad to query (if applicable), or -1 for any gamepad /// Whether the given control is down /// If the passed control isn't of a supported type public bool IsDown(GenericInput control, int index = -1) { switch (control.Type) { case GenericInput.InputType.Keyboard: return this.IsKeyDown(control); case GenericInput.InputType.Gamepad: return this.IsGamepadButtonDown(control, index); case GenericInput.InputType.Mouse: return this.IsMouseButtonDown(control); default: return false; } } /// /// Returns if a given control of any kind is up. /// This is a helper function that can be passed a , or . /// /// The control whose up state to query /// The index of the gamepad to query (if applicable), or -1 for any gamepad /// Whether the given control is up. /// If the passed control isn't of a supported type public bool IsUp(GenericInput control, int index = -1) { switch (control.Type) { case GenericInput.InputType.Keyboard: return this.IsKeyUp(control); case GenericInput.InputType.Gamepad: return this.IsGamepadButtonUp(control, index); case GenericInput.InputType.Mouse: return this.IsMouseButtonUp(control); default: return true; } } /// /// Returns if a given control of any kind is pressed. /// This is a helper function that can be passed a , or . /// /// The control whose pressed state to query /// The index of the gamepad to query (if applicable), or -1 for any gamepad /// Whether the given control is pressed. /// If the passed control isn't of a supported type public bool IsPressed(GenericInput control, int index = -1) { switch (control.Type) { case GenericInput.InputType.Keyboard: return this.IsKeyPressed(control); case GenericInput.InputType.Gamepad: return this.IsGamepadButtonPressed(control, index); case GenericInput.InputType.Mouse: return this.IsMouseButtonPressed(control); default: return false; } } /// /// Returns if a given control of any kind is pressed, and marks the press as consumed if it is. /// This is a helper function that can be passed a , or . /// /// The control whose pressed state to query. /// The index of the gamepad to query (if applicable), or -1 for any gamepad. /// Whether the given control is pressed and the press is not consumed yet. public bool TryConsumePressed(GenericInput control, int index = -1) { switch (control.Type) { case GenericInput.InputType.Keyboard: return this.TryConsumeKeyPressed(control); case GenericInput.InputType.Gamepad: return this.TryConsumeGamepadButtonPressed(control, index); case GenericInput.InputType.Mouse: return this.TryConsumeMouseButtonPressed(control); default: return false; } } /// public bool IsAnyDown(params GenericInput[] controls) { foreach (var control in controls) { if (this.IsDown(control)) return true; } return false; } /// public bool IsAnyUp(params GenericInput[] controls) { foreach (var control in controls) { if (this.IsUp(control)) return true; } return false; } /// public bool IsAnyPressed(params GenericInput[] controls) { foreach (var control in controls) { if (this.IsPressed(control)) return true; } return false; } /// /// Tries to retrieve the amount of time that a given has been held down for. /// If the input is currently down, this method returns true and the amount of time that it has been down for is stored in . /// /// The input whose down time to query. /// The resulting down time, or if the input is not being held. /// The index of the gamepad to query (if applicable), or -1 for any gamepad. /// Whether the input is currently being held. public bool TryGetDownTime(GenericInput input, out TimeSpan downTime, int index = -1) { if (this.inputsDown.TryGetValue((input, index), out var start)) { downTime = DateTime.UtcNow - start; return true; } return false; } /// /// Returns the amount of time that a given has been held down for. /// If this input isn't currently own, this method returns . /// /// The input whose down time to query. /// The index of the gamepad to query (if applicable), or -1 for any gamepad. /// The resulting down time, or if the input is not being held. public TimeSpan GetDownTime(GenericInput input, int index = -1) { this.TryGetDownTime(input, out var time, index); return time; } private void AccumulateDown(GenericInput input, int index) { this.inputsDownAccum.Add((input, index), this.inputsDown.TryGetValue((input, index), out var start) ? start : DateTime.UtcNow); } /// /// Helper function to enable gestures for a easily. /// Note that, if other gestures were previously enabled, they will not get overridden. /// /// The gestures to enable public static void EnableGestures(params GestureType[] gestures) { foreach (var gesture in gestures) TouchPanel.EnabledGestures |= gesture; } /// /// Helper function to disable gestures for a easily. /// /// The gestures to disable public static void DisableGestures(params GestureType[] gestures) { foreach (var gesture in gestures) TouchPanel.EnabledGestures &= ~gesture; } /// /// Helper function to enable or disable the given gestures for a easily. /// This method is equivalent to calling if the enabled value is true and calling if it is false. /// Note that, if other gestures were previously enabled, they will not get overridden. /// /// Whether to enable or disable the gestures /// The gestures to enable or disable public static void SetGesturesEnabled(bool enabled, params GestureType[] gestures) { if (enabled) { EnableGestures(gestures); } else { DisableGestures(gestures); } } } }