Input System
The Popcorning Guinea Pig engine provides a comprehensive input management system through the IInputService and related components, supporting keyboards, gamepads, and customizable input contexts.
Overview
The input system features:
- Context-based input handling with stacking
- Customizable key bindings and remapping
- Support for multiple input devices (keyboard, gamepad, mouse)
- Command pattern implementation for actions
- Input buffering and combo detection
- Real-time input binding changes
- Input recording and playback for testing
Core Components
IInputService Interface
public interface IInputService
{
bool IsKeyDown(InputKey key);
bool IsKeyPressed(InputKey key);
bool IsKeyReleased(InputKey key);
Vector2 GetMousePosition();
bool IsMouseButtonDown(MouseButton button);
bool IsMouseButtonPressed(MouseButton button);
GamepadState GetGamepadState(int playerIndex);
void Update();
void RegisterInputContext(InputContext context);
void PushContext(string contextName);
void PopContext();
void ClearContextStack();
}
Input Context System
The engine uses a context-based system where different contexts (menu, gameplay, pause) can have different input mappings:
public class InputContext
{
public string Name { get; set; }
public List<InputBinding> Bindings { get; set; }
public bool ConsumeInput { get; set; } // Prevents lower contexts from receiving input
public int Priority { get; set; }
}
public class InputBinding
{
public string ActionName { get; set; }
public InputKey Key { get; set; }
public InputKey[] ModifierKeys { get; set; }
public IInputCommand Command { get; set; }
}
Getting Started
Initialize Input System
public class MyGame : Game
{
private readonly IInputService _inputService;
private readonly InputManager _inputManager;
public MyGame()
{
_inputService = GetService<IInputService>();
_inputManager = new InputManager(_inputService);
SetupInputContexts();
}
private void SetupInputContexts()
{
// Create gameplay context
var gameplayContext = new InputContext
{
Name = "Gameplay",
ConsumeInput = true,
Bindings = new List<InputBinding>
{
new InputBinding
{
ActionName = "MoveLeft",
Key = InputKey.A,
Command = new MoveCommand(Vector2.Left)
},
new InputBinding
{
ActionName = "MoveRight",
Key = InputKey.D,
Command = new MoveCommand(Vector2.Right)
},
new InputBinding
{
ActionName = "Jump",
Key = InputKey.Space,
Command = new JumpCommand()
}
}
};
// Create menu context
var menuContext = new InputContext
{
Name = "Menu",
ConsumeInput = true,
Bindings = new List<InputBinding>
{
new InputBinding
{
ActionName = "Navigate",
Key = InputKey.ArrowUp,
Command = new NavigateCommand(Direction.Up)
},
new InputBinding
{
ActionName = "Select",
Key = InputKey.Enter,
Command = new SelectCommand()
},
new InputBinding
{
ActionName = "Back",
Key = InputKey.Escape,
Command = new BackCommand()
}
}
};
_inputManager.RegisterContext(gameplayContext);
_inputManager.RegisterContext(menuContext);
}
protected override void Update(GameTime gameTime)
{
_inputService.Update();
_inputManager.ProcessInput();
base.Update(gameTime);
}
}
Input Commands
Implement the command pattern for actions:
public interface IInputCommand
{
void Execute();
void Undo(); // For commands that support undo
bool CanExecute();
}
// Movement command
public class MoveCommand : IInputCommand
{
private readonly Vector2 _direction;
private readonly Player _player;
public MoveCommand(Vector2 direction)
{
_direction = direction;
_player = Game.Current.Player; // Get player reference
}
public void Execute()
{
if (CanExecute())
{
_player.Move(_direction);
}
}
public void Undo()
{
_player.Move(-_direction);
}
public bool CanExecute()
{
return _player != null && _player.CanMove;
}
}
// Jump command
public class JumpCommand : IInputCommand
{
private readonly Player _player;
public JumpCommand()
{
_player = Game.Current.Player;
}
public void Execute()
{
if (CanExecute())
{
_player.Jump();
}
}
public void Undo()
{
// Jumping usually can't be undone
}
public bool CanExecute()
{
return _player != null && _player.IsGrounded;
}
}
// UI navigation command
public class NavigateCommand : IInputCommand
{
private readonly Direction _direction;
private readonly UINavigator _navigator;
public NavigateCommand(Direction direction)
{
_direction = direction;
_navigator = Game.Current.UINavigator;
}
public void Execute()
{
_navigator.Navigate(_direction);
}
public void Undo() { }
public bool CanExecute()
{
return _navigator != null;
}
}
Context Management
Context Stack
Manage input contexts using a stack system:
public class InputManager
{
private readonly IInputService _inputService;
private readonly Dictionary<string, InputContext> _contexts;
private readonly Stack<string> _contextStack;
public InputManager(IInputService inputService)
{
_inputService = inputService;
_contexts = new Dictionary<string, InputContext>();
_contextStack = new Stack<string>();
}
public void RegisterContext(InputContext context)
{
_contexts[context.Name] = context;
}
public void PushContext(string contextName)
{
if (_contexts.ContainsKey(contextName))
{
_contextStack.Push(contextName);
}
}
public void PopContext()
{
if (_contextStack.Count > 0)
{
_contextStack.Pop();
}
}
public void ProcessInput()
{
var processedActions = new HashSet<string>();
// Process contexts from top of stack (most recent) to bottom
foreach (var contextName in _contextStack.Reverse())
{
var context = _contexts[contextName];
foreach (var binding in context.Bindings)
{
// Skip if action already processed by higher context
if (processedActions.Contains(binding.ActionName))
continue;
if (IsBindingTriggered(binding))
{
binding.Command?.Execute();
processedActions.Add(binding.ActionName);
// If context consumes input, don't process lower contexts
if (context.ConsumeInput)
break;
}
}
}
}
private bool IsBindingTriggered(InputBinding binding)
{
// Check if main key is pressed
if (!_inputService.IsKeyPressed(binding.Key))
return false;
// Check modifier keys
if (binding.ModifierKeys != null)
{
foreach (var modifierKey in binding.ModifierKeys)
{
if (!_inputService.IsKeyDown(modifierKey))
return false;
}
}
return true;
}
}
Scene-Based Context Switching
Automatically switch contexts based on current scene:
public abstract class Scene
{
protected InputManager _inputManager;
protected virtual void OnSceneActivated()
{
_inputManager = GetService<InputManager>();
SetupInputContext();
}
protected virtual void OnSceneDeactivated()
{
_inputManager.PopContext();
}
protected abstract void SetupInputContext();
}
public class GameplayScene : Scene
{
protected override void SetupInputContext()
{
_inputManager.PushContext("Gameplay");
}
}
public class MainMenuScene : Scene
{
protected override void SetupInputContext()
{
_inputManager.PushContext("Menu");
}
}
public class PauseMenuScene : Scene
{
protected override void SetupInputContext()
{
// Push pause context on top of existing contexts
_inputManager.PushContext("PauseMenu");
}
protected override void OnSceneDeactivated()
{
// Pop pause context to return to gameplay
_inputManager.PopContext();
}
}
Advanced Features
Input Buffering
Buffer inputs for precise timing in games like fighting games:
public class InputBuffer
{
private readonly Queue<InputEvent> _buffer;
private readonly int _bufferSize;
private readonly float _bufferTime;
public InputBuffer(int bufferSize = 10, float bufferTime = 0.2f)
{
_buffer = new Queue<InputEvent>(bufferSize);
_bufferSize = bufferSize;
_bufferTime = bufferTime;
}
public void AddInput(InputKey key, float timestamp)
{
var inputEvent = new InputEvent
{
Key = key,
Timestamp = timestamp
};
_buffer.Enqueue(inputEvent);
// Remove old events
while (_buffer.Count > _bufferSize)
{
_buffer.Dequeue();
}
CleanExpiredEvents(timestamp);
}
public bool HasRecentInput(InputKey key, float currentTime, float timeWindow = -1f)
{
if (timeWindow < 0)
timeWindow = _bufferTime;
return _buffer.Any(e => e.Key == key &&
(currentTime - e.Timestamp) <= timeWindow);
}
public bool HasInputSequence(InputKey[] sequence, float currentTime, float timeWindow = -1f)
{
if (timeWindow < 0)
timeWindow = _bufferTime;
var relevantEvents = _buffer
.Where(e => (currentTime - e.Timestamp) <= timeWindow)
.OrderBy(e => e.Timestamp)
.ToList();
return ContainsSequence(relevantEvents, sequence);
}
private void CleanExpiredEvents(float currentTime)
{
while (_buffer.Count > 0 &&
(currentTime - _buffer.Peek().Timestamp) > _bufferTime)
{
_buffer.Dequeue();
}
}
}
public struct InputEvent
{
public InputKey Key;
public float Timestamp;
}
Combo System
Implement combo detection for complex input sequences:
public class ComboSystem
{
private readonly InputBuffer _inputBuffer;
private readonly Dictionary<string, ComboDefinition> _combos;
public ComboSystem()
{
_inputBuffer = new InputBuffer();
_combos = new Dictionary<string, ComboDefinition>();
SetupCombos();
}
private void SetupCombos()
{
// Fireball combo: Down, Down-Forward, Forward + Punch
_combos["Fireball"] = new ComboDefinition
{
Name = "Fireball",
Sequence = new[] { InputKey.S, InputKey.S | InputKey.D, InputKey.D, InputKey.J },
TimeWindow = 1.0f,
Command = new FireballCommand()
};
// Dragon Punch: Forward, Down, Down-Forward + Punch
_combos["DragonPunch"] = new ComboDefinition
{
Name = "DragonPunch",
Sequence = new[] { InputKey.D, InputKey.S, InputKey.S | InputKey.D, InputKey.J },
TimeWindow = 0.8f,
Command = new DragonPunchCommand()
};
}
public void ProcessInput(InputKey key, float timestamp)
{
_inputBuffer.AddInput(key, timestamp);
// Check for combo matches
foreach (var combo in _combos.Values)
{
if (_inputBuffer.HasInputSequence(combo.Sequence, timestamp, combo.TimeWindow))
{
combo.Command.Execute();
OnComboExecuted?.Invoke(combo.Name);
}
}
}
public event Action<string> OnComboExecuted;
}
public class ComboDefinition
{
public string Name { get; set; }
public InputKey[] Sequence { get; set; }
public float TimeWindow { get; set; }
public IInputCommand Command { get; set; }
}
Input Remapping
Allow players to customize their key bindings:
public class InputRemapper
{
private readonly Dictionary<string, InputContext> _contexts;
private readonly ISettingsService _settings;
public InputRemapper(ISettingsService settings)
{
_settings = settings;
_contexts = new Dictionary<string, InputContext>();
LoadBindings();
}
public void RemapBinding(string contextName, string actionName, InputKey newKey)
{
if (!_contexts.ContainsKey(contextName))
return;
var context = _contexts[contextName];
var binding = context.Bindings.FirstOrDefault(b => b.ActionName == actionName);
if (binding != null)
{
binding.Key = newKey;
SaveBinding(contextName, actionName, newKey);
OnBindingChanged?.Invoke(contextName, actionName, newKey);
}
}
public InputKey GetBinding(string contextName, string actionName)
{
if (!_contexts.ContainsKey(contextName))
return InputKey.Unknown;
var context = _contexts[contextName];
var binding = context.Bindings.FirstOrDefault(b => b.ActionName == actionName);
return binding?.Key ?? InputKey.Unknown;
}
public void ResetToDefaults(string contextName)
{
if (!_contexts.ContainsKey(contextName))
return;
var context = _contexts[contextName];
foreach (var binding in context.Bindings)
{
var defaultKey = GetDefaultBinding(contextName, binding.ActionName);
binding.Key = defaultKey;
SaveBinding(contextName, binding.ActionName, defaultKey);
}
OnContextReset?.Invoke(contextName);
}
private void SaveBinding(string contextName, string actionName, InputKey key)
{
var settingKey = $"Input.{contextName}.{actionName}";
_settings.SetSetting(settingKey, key.ToString());
}
private InputKey LoadBinding(string contextName, string actionName, InputKey defaultKey)
{
var settingKey = $"Input.{contextName}.{actionName}";
var keyString = _settings.GetSetting(settingKey, defaultKey.ToString());
return Enum.TryParse<InputKey>(keyString, out var key) ? key : defaultKey;
}
public event Action<string, string, InputKey> OnBindingChanged;
public event Action<string> OnContextReset;
}
Input Recording and Playback
Record and replay input for testing and demos:
public class InputRecorder
{
private readonly List<RecordedInput> _recording;
private readonly List<RecordedInput> _playback;
private bool _isRecording;
private bool _isPlayingBack;
private float _startTime;
private int _playbackIndex;
public InputRecorder()
{
_recording = new List<RecordedInput>();
_playback = new List<RecordedInput>();
}
public void StartRecording()
{
_recording.Clear();
_isRecording = true;
_startTime = Time.CurrentTime;
}
public void StopRecording()
{
_isRecording = false;
}
public void RecordInput(InputKey key, bool isPressed, float timestamp)
{
if (!_isRecording)
return;
_recording.Add(new RecordedInput
{
Key = key,
IsPressed = isPressed,
Timestamp = timestamp - _startTime
});
}
public void StartPlayback()
{
_playback.Clear();
_playback.AddRange(_recording);
_isPlayingBack = true;
_playbackIndex = 0;
_startTime = Time.CurrentTime;
}
public void StopPlayback()
{
_isPlayingBack = false;
_playbackIndex = 0;
}
public void UpdatePlayback(float currentTime, Action<InputKey, bool> inputCallback)
{
if (!_isPlayingBack || _playbackIndex >= _playback.Count)
return;
var relativeTime = currentTime - _startTime;
while (_playbackIndex < _playback.Count &&
_playback[_playbackIndex].Timestamp <= relativeTime)
{
var input = _playback[_playbackIndex];
inputCallback?.Invoke(input.Key, input.IsPressed);
_playbackIndex++;
}
if (_playbackIndex >= _playback.Count)
{
StopPlayback();
}
}
public void SaveRecording(string filename)
{
var json = JsonSerializer.Serialize(_recording, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(filename, json);
}
public void LoadRecording(string filename)
{
if (File.Exists(filename))
{
var json = File.ReadAllText(filename);
_recording.Clear();
_recording.AddRange(JsonSerializer.Deserialize<List<RecordedInput>>(json));
}
}
}
public struct RecordedInput
{
public InputKey Key { get; set; }
public bool IsPressed { get; set; }
public float Timestamp { get; set; }
}
Gamepad Support
Gamepad Input Handling
public class GamepadManager
{
private readonly IInputService _inputService;
private readonly Dictionary<int, GamepadState> _previousStates;
public GamepadManager(IInputService inputService)
{
_inputService = inputService;
_previousStates = new Dictionary<int, GamepadState>();
}
public void Update()
{
for (int i = 0; i < 4; i++) // Support up to 4 gamepads
{
var currentState = _inputService.GetGamepadState(i);
var previousState = _previousStates.GetValueOrDefault(i);
ProcessGamepadInput(i, currentState, previousState);
_previousStates[i] = currentState;
}
}
private void ProcessGamepadInput(int playerIndex, GamepadState current, GamepadState previous)
{
// Check button presses
foreach (var button in Enum.GetValues<GamepadButton>())
{
if (IsButtonPressed(current, previous, button))
{
OnButtonPressed?.Invoke(playerIndex, button);
}
if (IsButtonReleased(current, previous, button))
{
OnButtonReleased?.Invoke(playerIndex, button);
}
}
// Check analog stick movement
if (Vector2.Distance(current.LeftStick, previous.LeftStick) > 0.1f)
{
OnLeftStickMoved?.Invoke(playerIndex, current.LeftStick);
}
if (Vector2.Distance(current.RightStick, previous.RightStick) > 0.1f)
{
OnRightStickMoved?.Invoke(playerIndex, current.RightStick);
}
// Check trigger changes
if (Math.Abs(current.LeftTrigger - previous.LeftTrigger) > 0.05f)
{
OnLeftTriggerMoved?.Invoke(playerIndex, current.LeftTrigger);
}
if (Math.Abs(current.RightTrigger - previous.RightTrigger) > 0.05f)
{
OnRightTriggerMoved?.Invoke(playerIndex, current.RightTrigger);
}
}
private bool IsButtonPressed(GamepadState current, GamepadState previous, GamepadButton button)
{
return current.IsButtonPressed(button) && !previous.IsButtonPressed(button);
}
private bool IsButtonReleased(GamepadState current, GamepadState previous, GamepadButton button)
{
return !current.IsButtonPressed(button) && previous.IsButtonPressed(button);
}
public event Action<int, GamepadButton> OnButtonPressed;
public event Action<int, GamepadButton> OnButtonReleased;
public event Action<int, Vector2> OnLeftStickMoved;
public event Action<int, Vector2> OnRightStickMoved;
public event Action<int, float> OnLeftTriggerMoved;
public event Action<int, float> OnRightTriggerMoved;
}
Best Practices
Input Responsiveness
- Update frequency: Process input every frame for responsiveness
- Input buffering: Use buffering for precise timing requirements
- Dead zones: Implement dead zones for analog inputs to prevent drift
- Input validation: Validate input commands before execution
Context Management
- Clear hierarchy: Design clear input context hierarchy
- Context cleanup: Always pop contexts when scenes change
- Priority system: Use priority for overlapping contexts
- Input consumption: Use input consumption to prevent unwanted actions
Customization
- Key remapping: Allow players to customize controls
- Multiple devices: Support both keyboard and gamepad simultaneously
- Accessibility: Provide options for different input methods
- Save preferences: Persist custom key bindings
Example: Complete Input Setup
public class GameInputManager
{
private readonly IInputService _inputService;
private readonly InputManager _inputManager;
private readonly InputRemapper _remapper;
private readonly ComboSystem _comboSystem;
public GameInputManager()
{
_inputService = GetService<IInputService>();
_inputManager = new InputManager(_inputService);
_remapper = new InputRemapper(GetService<ISettingsService>());
_comboSystem = new ComboSystem();
SetupDefaultContexts();
LoadCustomBindings();
}
private void SetupDefaultContexts()
{
// Create and register all input contexts
var contexts = new[]
{
CreateGameplayContext(),
CreateMenuContext(),
CreatePauseContext(),
CreateSettingsContext()
};
foreach (var context in contexts)
{
_inputManager.RegisterContext(context);
}
}
private InputContext CreateGameplayContext()
{
return new InputContext
{
Name = "Gameplay",
ConsumeInput = true,
Bindings = new List<InputBinding>
{
new InputBinding { ActionName = "MoveLeft", Key = InputKey.A, Command = new MoveCommand(Vector2.Left) },
new InputBinding { ActionName = "MoveRight", Key = InputKey.D, Command = new MoveCommand(Vector2.Right) },
new InputBinding { ActionName = "Jump", Key = InputKey.Space, Command = new JumpCommand() },
new InputBinding { ActionName = "Attack", Key = InputKey.J, Command = new AttackCommand() },
new InputBinding { ActionName = "Pause", Key = InputKey.Escape, Command = new PauseCommand() }
}
};
}
public void Update(float deltaTime)
{
_inputService.Update();
_inputManager.ProcessInput();
_comboSystem.ProcessInput(GetCurrentInput(), Time.CurrentTime);
}
public void SwitchToContext(string contextName)
{
_inputManager.ClearContextStack();
_inputManager.PushContext(contextName);
}
public void PushContext(string contextName)
{
_inputManager.PushContext(contextName);
}
public void PopContext()
{
_inputManager.PopContext();
}
}\n```
The input system provides a flexible and powerful foundation for handling all player interactions in your Popcorning Guinea Pig games.