Input System
The Popcorning Guinea Pig engine features a sophisticated input system built on the command pattern, providing flexible input handling through contextual bindings, priority-based resolution, and command execution. The system supports keyboard, mouse, and gamepad inputs with full customization and PGPSCN integration.
Overview
The input system provides:
- Command Pattern: Decoupled input handling through command objects
- Context Stack: Priority-based input context layering (menus over gameplay)
- Input Bindings: Flexible key-to-command mappings with modifiers
- Multi-Device Support: Keyboard, mouse, and gamepad input
- PGPSCN Integration: Define input bindings declaratively
- Real-time Polling: High-performance input state tracking
- Custom Commands: Extensible command system
Core Architecture
Input Flow
Raw Input → InputManager → InputKey → ContextStack → InputBinding → InputCommand → Execute
Key Components
- InputManager: Low-level input polling and event generation
- ContextStack: Priority-based context resolution
- InputContext: Named collections of input bindings
- InputBinding: Maps input keys to commands
- InputCommand: Executable actions triggered by input
Input Manager
The InputManager handles raw input polling and state tracking:
using PopcorningGuineaPig.Engine.Input;
public class GameInputManager
{
private InputManager inputManager;
public void Initialize()
{
inputManager = new InputManager();
inputManager.OnInputChanged += HandleInputEvent;
}
public void Update()
{
inputManager.Update();
}
private void HandleInputEvent(InputEvent inputEvent)
{
// Process input changes
Console.WriteLine($"Input: {inputEvent.Key}, Trigger: {inputEvent.Trigger}");
}
}
Input Events
Input events contain:
- InputKey: The key/button that changed
- InputTriggerType: Pressed, Released, or Held
- Modifiers: Ctrl, Shift, Alt combinations
public enum InputTriggerType
{
Pressed,
Released,
Held
}
[Flags]
public enum InputModifiers
{
None = 0,
Ctrl = 1,
Shift = 2,
Alt = 4
}
Input Keys
Input keys represent different input sources:
using PopcorningGuineaPig.Engine.Input;
// Keyboard input
var moveUp = InputKey.Keyboard(KeyboardKey.W, InputModifiers.None);
var quickSave = InputKey.Keyboard(KeyboardKey.S, InputModifiers.Ctrl);
// Mouse input
var primaryClick = InputKey.Mouse(MouseButton.Left, InputModifiers.None);
var contextMenu = InputKey.Mouse(MouseButton.Right, InputModifiers.None);
// Gamepad input
var jump = InputKey.Gamepad(GamepadButton.A, gamepadIndex: 0);
var pause = InputKey.Gamepad(GamepadButton.Start, gamepadIndex: 0);
Input Commands
Commands implement the IInputCommand interface:
public interface IInputCommand
{
string Id { get; }
bool CanExecute(object[] parameters = null);
Task ExecuteAsync(object[] parameters = null);
void Execute(object[] parameters = null);
}
Basic Command Implementation
public class MovePlayerCommand : IInputCommand
{
public string Id => "move_player";
private readonly PlayerController player;
private readonly Vector3 direction;
public MovePlayerCommand(PlayerController player, Vector3 direction)
{
this.player = player;
this.direction = direction;
}
public bool CanExecute(object[] parameters = null)
{
return player != null && player.CanMove;
}
public void Execute(object[] parameters = null)
{
if (CanExecute(parameters))
{
player.Move(direction);
}
}
public async Task ExecuteAsync(object[] parameters = null)
{
Execute(parameters);
await Task.CompletedTask;
}
}
public class SaveGameCommand : IInputCommand
{
public string Id => "save_game";
public bool CanExecute(object[] parameters = null)
{
return GameManager.CanSave;
}
public void Execute(object[] parameters = null)
{
if (CanExecute())
{
GameManager.SaveGame();
ShowNotification("Game Saved!");
}
}
public async Task ExecuteAsync(object[] parameters = null)
{
if (CanExecute())
{
await GameManager.SaveGameAsync();
ShowNotification("Game Saved!");
}
}
}
Scene Method Commands
Execute methods on scene objects:
public class SceneMethodCommand : IInputCommand
{
public string Id { get; }
private readonly Scene scene;
private readonly string methodName;
private readonly object[] methodParameters;
public SceneMethodCommand(string id, Scene scene, string methodName, object[] parameters = null)
{
Id = id;
this.scene = scene;
this.methodName = methodName;
this.methodParameters = parameters;
}
public bool CanExecute(object[] parameters = null)
{
return scene != null;
}
public void Execute(object[] parameters = null)
{
if (CanExecute())
{
var method = scene.GetType().GetMethod(methodName);
method?.Invoke(scene, methodParameters);
}
}
public async Task ExecuteAsync(object[] parameters = null)
{
Execute(parameters);
await Task.CompletedTask;
}
}
Input Contexts
Contexts group related input bindings with priority:
using PopcorningGuineaPig.Engine.Input;
public class GameplayInputContext : InputContext
{
public GameplayInputContext() : base("gameplay", priority: 100)
{
SetupGameplayBindings();
}
private void SetupGameplayBindings()
{
// Movement
AddBinding(InputKey.Keyboard(KeyboardKey.W), new MovePlayerCommand(player, Vector3.Forward));
AddBinding(InputKey.Keyboard(KeyboardKey.A), new MovePlayerCommand(player, Vector3.Left));
AddBinding(InputKey.Keyboard(KeyboardKey.S), new MovePlayerCommand(player, Vector3.Back));
AddBinding(InputKey.Keyboard(KeyboardKey.D), new MovePlayerCommand(player, Vector3.Right));
// Actions
AddBinding(InputKey.Keyboard(KeyboardKey.Space), new JumpCommand(player));
AddBinding(InputKey.Mouse(MouseButton.Left), new AttackCommand(player));
// System
AddBinding(InputKey.Keyboard(KeyboardKey.Escape), new OpenPauseMenuCommand());
AddBinding(InputKey.Keyboard(KeyboardKey.S, InputModifiers.Ctrl), new SaveGameCommand());
}
}
public class MenuInputContext : InputContext
{
public MenuInputContext() : base("menu", priority: 200) // Higher priority than gameplay
{
AllowPassthrough = false; // Block lower priority contexts
SetupMenuBindings();
}
private void SetupMenuBindings()
{
AddBinding(InputKey.Keyboard(KeyboardKey.Up), new NavigateMenuCommand("up"));
AddBinding(InputKey.Keyboard(KeyboardKey.Down), new NavigateMenuCommand("down"));
AddBinding(InputKey.Keyboard(KeyboardKey.Enter), new SelectMenuItemCommand());
AddBinding(InputKey.Keyboard(KeyboardKey.Escape), new CloseMenuCommand());
}
}
Context Stack
The context stack manages input priority and resolution:
using PopcorningGuineaPig.Engine.Input;
public class InputSystem
{
private ContextStack contextStack;
private InputManager inputManager;
public void Initialize()
{
contextStack = new ContextStack();
inputManager = new InputManager();
// Set up global context for system-wide commands
var globalContext = new InputContext("global", priority: 0);
globalContext.AddBinding(
InputKey.Keyboard(KeyboardKey.F11),
new ToggleFullscreenCommand());
contextStack.SetGlobalContext(globalContext);
inputManager.OnInputChanged += ProcessInputEvent;
}
public void PushContext(InputContext context)
{
contextStack.Push(context);
}
public void PopContext(string contextName)
{
contextStack.Pop(contextName);
}
private void ProcessInputEvent(InputEvent inputEvent)
{
if (inputEvent.Trigger == InputTriggerType.Pressed)
{
var binding = contextStack.ResolveBinding(inputEvent.Key);
if (binding != null && binding.Command.CanExecute())
{
binding.Command.Execute();
}
}
}
public void Update()
{
inputManager.Update();
}
}
Context Layering Example
public class GameScene : Scene
{
private InputSystem inputSystem;
private GameplayInputContext gameplayContext;
private MenuInputContext menuContext;
public override void Load()
{
inputSystem = new InputSystem();
inputSystem.Initialize();
// Always active gameplay context
gameplayContext = new GameplayInputContext();
inputSystem.PushContext(gameplayContext);
}
public void OpenPauseMenu()
{
// Menu context takes priority over gameplay
menuContext = new MenuInputContext();
inputSystem.PushContext(menuContext);
}
public void ClosePauseMenu()
{
// Remove menu context, gameplay resumes
inputSystem.PopContext("menu");
menuContext = null;
}
}
Input Bindings
Bindings connect input keys to commands:
public class InputBinding
{
public InputKey Key { get; }
public IInputCommand Command { get; }
public bool Enabled { get; set; } = true;
public InputBinding(InputKey key, IInputCommand command)
{
Key = key;
Command = command;
}
}
// Usage
var binding = new InputBinding(
InputKey.Keyboard(KeyboardKey.Space),
new JumpCommand(player)
);
// Conditional enabling
binding.Enabled = player.IsAlive && player.OnGround;
Dynamic Binding Modification
public class InputBindingManager
{
private Dictionary<string, InputBinding> bindings = new();
public void AddBinding(string id, InputKey key, IInputCommand command)
{
bindings[id] = new InputBinding(key, command);
}
public void RemoveBinding(string id)
{
bindings.Remove(id);
}
public void EnableBinding(string id, bool enabled = true)
{
if (bindings.TryGetValue(id, out var binding))
{
binding.Enabled = enabled;
}
}
public void RebindKey(string id, InputKey newKey)
{
if (bindings.TryGetValue(id, out var binding))
{
bindings[id] = new InputBinding(newKey, binding.Command);
}
}
}
PGPSCN Integration
Define input bindings in scene files:
{
"version": "1.0",
"sceneType": "2d",
"input_contexts": [
{
"name": "gameplay",
"priority": 100,
"allowPassthrough": true,
"bindings": [
{
"key": {
"type": "keyboard",
"key": "W",
"modifiers": []
},
"command": {
"type": "scene_method",
"method": "MovePlayer",
"parameters": ["forward"]
}
},
{
"key": {
"type": "keyboard",
"key": "Space"
},
"command": {
"type": "scene_method",
"method": "PlayerJump"
}
},
{
"key": {
"type": "mouse",
"button": "Left"
},
"command": {
"type": "scene_method",
"method": "PlayerAttack"
}
},
{
"key": {
"type": "gamepad",
"button": "A",
"gamepadIndex": 0
},
"command": {
"type": "scene_method",
"method": "PlayerJump"
}
}
]
},
{
"name": "menu",
"priority": 200,
"allowPassthrough": false,
"bindings": [
{
"key": {
"type": "keyboard",
"key": "Up"
},
"command": {
"type": "scene_method",
"method": "NavigateMenu",
"parameters": ["up"]
}
},
{
"key": {
"type": "keyboard",
"key": "Escape"
},
"command": {
"type": "scene_method",
"method": "CloseMenu"
}
}
]
}
],
"global_bindings": [
{
"key": {
"type": "keyboard",
"key": "F11"
},
"command": {
"type": "static_method",
"class": "DisplayManager",
"method": "ToggleFullscreen"
}
},
{
"key": {
"type": "keyboard",
"key": "S",
"modifiers": ["Ctrl"]
},
"command": {
"type": "static_method",
"class": "GameManager",
"method": "SaveGame"
}
}
]
}
Loading Input Contexts from PGPSCN
public class InputContextLoader
{
public void LoadFromPGPSCN(string scenePath, Scene scene, InputSystem inputSystem)
{
var sceneData = JsonSerializer.Deserialize<SceneData>(File.ReadAllText(scenePath));
// Load input contexts
foreach (var contextDef in sceneData.InputContexts ?? new())
{
var context = new InputContext(contextDef.Name, contextDef.Priority)
{
AllowPassthrough = contextDef.AllowPassthrough
};
// Load bindings
foreach (var bindingDef in contextDef.Bindings)
{
var inputKey = CreateInputKey(bindingDef.Key);
var command = CreateCommand(bindingDef.Command, scene);
context.AddBinding(inputKey, command);
}
inputSystem.PushContext(context);
}
// Load global bindings
if (sceneData.GlobalBindings?.Any() == true)
{
var globalContext = new InputContext("global", priority: 0);
foreach (var bindingDef in sceneData.GlobalBindings)
{
var inputKey = CreateInputKey(bindingDef.Key);
var command = CreateCommand(bindingDef.Command, scene);
globalContext.AddBinding(inputKey, command);
}
inputSystem.SetGlobalContext(globalContext);
}
}
private InputKey CreateInputKey(InputKeyDefinition keyDef)
{
var modifiers = ParseModifiers(keyDef.Modifiers);
return keyDef.Type switch
{
"keyboard" => InputKey.Keyboard(Enum.Parse<KeyboardKey>(keyDef.Key), modifiers),
"mouse" => InputKey.Mouse(Enum.Parse<MouseButton>(keyDef.Button), modifiers),
"gamepad" => InputKey.Gamepad(Enum.Parse<GamepadButton>(keyDef.Button), keyDef.GamepadIndex),
_ => throw new NotSupportedException($"Input type '{keyDef.Type}' not supported")
};
}
private IInputCommand CreateCommand(CommandDefinition cmdDef, Scene scene)
{
return cmdDef.Type switch
{
"scene_method" => new SceneMethodCommand(
cmdDef.Method, scene, cmdDef.Method, cmdDef.Parameters),
"static_method" => new StaticMethodCommand(
cmdDef.Class, cmdDef.Method, cmdDef.Parameters),
_ => throw new NotSupportedException($"Command type '{cmdDef.Type}' not supported")
};
}
}
Advanced Features
Input Buffering
Buffer inputs for combo systems and precise timing:
public class InputBuffer
{
private readonly Queue<InputEvent> buffer = new();
private readonly float bufferDuration;
private readonly Dictionary<InputEvent, DateTime> timestamps = new();
public InputBuffer(float bufferDurationSeconds = 0.2f)
{
bufferDuration = bufferDurationSeconds;
}
public void AddInput(InputEvent inputEvent)
{
buffer.Enqueue(inputEvent);
timestamps[inputEvent] = DateTime.Now;
CleanOldInputs();
}
public bool ConsumeInput(InputKey expectedKey)
{
var toRemove = new List<InputEvent>();
bool found = false;
foreach (var inputEvent in buffer)
{
if (inputEvent.Key.Equals(expectedKey))
{
found = true;
toRemove.Add(inputEvent);
break;
}
}
// Remove consumed input
foreach (var eventToRemove in toRemove)
{
var tempQueue = new Queue<InputEvent>();
while (buffer.Count > 0)
{
var evt = buffer.Dequeue();
if (evt != eventToRemove)
tempQueue.Enqueue(evt);
}
while (tempQueue.Count > 0)
buffer.Enqueue(tempQueue.Dequeue());
timestamps.Remove(eventToRemove);
}
return found;
}
private void CleanOldInputs()
{
var cutoff = DateTime.Now.AddSeconds(-bufferDuration);
var toRemove = new List<InputEvent>();
foreach (var kvp in timestamps)
{
if (kvp.Value < cutoff)
toRemove.Add(kvp.Key);
}
foreach (var eventToRemove in toRemove)
{
timestamps.Remove(eventToRemove);
// Remove from buffer as well
var tempQueue = new Queue<InputEvent>();
while (buffer.Count > 0)
{
var evt = buffer.Dequeue();
if (evt != eventToRemove)
tempQueue.Enqueue(evt);
}
while (tempQueue.Count > 0)
buffer.Enqueue(tempQueue.Dequeue());
}
}
}
Combo System
Implement input combos using sequences:
public class ComboSystem
{
private readonly List<ComboSequence> combos = new();
private readonly InputBuffer inputBuffer = new();
public void RegisterCombo(ComboSequence combo)
{
combos.Add(combo);
}
public void ProcessInput(InputEvent inputEvent)
{
inputBuffer.AddInput(inputEvent);
// Check all combos
foreach (var combo in combos)
{
if (combo.CheckSequence(inputBuffer))
{
combo.Execute();
break; // Only execute first matching combo
}
}
}
}
public class ComboSequence
{
public InputKey[] Sequence { get; }
public IInputCommand Command { get; }
public float MaxTimeBetweenInputs { get; }
public ComboSequence(InputKey[] sequence, IInputCommand command, float maxTime = 1.0f)
{
Sequence = sequence;
Command = command;
MaxTimeBetweenInputs = maxTime;
}
public bool CheckSequence(InputBuffer buffer)
{
// Implementation to check if the buffer contains the required sequence
// within the time limit
return false; // Simplified for brevity
}
public void Execute()
{
if (Command.CanExecute())
{
Command.Execute();
}
}
}
// Usage
var fireballCombo = new ComboSequence(
new[] {
InputKey.Keyboard(KeyboardKey.Down),
InputKey.Keyboard(KeyboardKey.Right),
InputKey.Keyboard(KeyboardKey.A)
},
new FireballCommand(),
maxTime: 0.8f
);
comboSystem.RegisterCombo(fireballCombo);
Input Recording and Playback
Record and replay input sequences:
public class InputRecorder
{
private List<TimestampedInput> recording = new();
private bool isRecording = false;
private DateTime recordingStart;
public void StartRecording()
{
isRecording = true;
recordingStart = DateTime.Now;
recording.Clear();
}
public void StopRecording()
{
isRecording = false;
}
public void RecordInput(InputEvent inputEvent)
{
if (isRecording)
{
var elapsed = (DateTime.Now - recordingStart).TotalSeconds;
recording.Add(new TimestampedInput(inputEvent, elapsed));
}
}
public async Task PlaybackRecording(InputSystem inputSystem)
{
var startTime = DateTime.Now;
foreach (var timestampedInput in recording)
{
var elapsed = (DateTime.Now - startTime).TotalSeconds;
var waitTime = timestampedInput.Timestamp - elapsed;
if (waitTime > 0)
{
await Task.Delay(TimeSpan.FromSeconds(waitTime));
}
// Simulate input event
inputSystem.ProcessInputEvent(timestampedInput.InputEvent);
}
}
}
public class TimestampedInput
{
public InputEvent InputEvent { get; }
public double Timestamp { get; }
public TimestampedInput(InputEvent inputEvent, double timestamp)
{
InputEvent = inputEvent;
Timestamp = timestamp;
}
}
Best Practices
Performance Optimization
- Efficient Context Management: Only push/pop contexts when necessary
- Command Caching: Reuse command instances where possible
- Input Filtering: Filter irrelevant inputs early
- Batch Processing: Group input events in high-frequency scenarios
public class OptimizedInputSystem
{
private readonly Dictionary<string, IInputCommand> commandCache = new();
private readonly HashSet<InputKey> relevantKeys = new();
public void RegisterRelevantKey(InputKey key)
{
relevantKeys.Add(key);
}
private void ProcessInputEvent(InputEvent inputEvent)
{
// Filter irrelevant inputs
if (!relevantKeys.Contains(inputEvent.Key))
return;
// Use cached commands
var binding = contextStack.ResolveBinding(inputEvent.Key);
if (binding?.Command != null)
{
var commandId = binding.Command.Id;
if (!commandCache.ContainsKey(commandId))
{
commandCache[commandId] = binding.Command;
}
commandCache[commandId].Execute();
}
}
}
Input Validation
public abstract class ValidatedInputCommand : IInputCommand
{
public abstract string Id { get; }
public bool CanExecute(object[] parameters = null)
{
return ValidateInput(parameters) && CanExecuteInternal(parameters);
}
protected abstract bool ValidateInput(object[] parameters);
protected abstract bool CanExecuteInternal(object[] parameters);
public void Execute(object[] parameters = null)
{
if (CanExecute(parameters))
{
ExecuteInternal(parameters);
}
}
protected abstract void ExecuteInternal(object[] parameters);
public async Task ExecuteAsync(object[] parameters = null)
{
if (CanExecute(parameters))
{
await ExecuteAsyncInternal(parameters);
}
}
protected abstract Task ExecuteAsyncInternal(object[] parameters);
}
public class MovePlayerValidatedCommand : ValidatedInputCommand
{
public override string Id => "move_player_validated";
protected override bool ValidateInput(object[] parameters)
{
return parameters?.Length >= 1 && parameters[0] is Vector3;
}
protected override bool CanExecuteInternal(object[] parameters)
{
return GameManager.Player?.CanMove == true;
}
protected override void ExecuteInternal(object[] parameters)
{
var direction = (Vector3)parameters[0];
GameManager.Player.Move(direction);
}
protected override async Task ExecuteAsyncInternal(object[] parameters)
{
ExecuteInternal(parameters);
await Task.CompletedTask;
}
}
Context Management
public class ContextManager
{
private readonly InputSystem inputSystem;
private readonly Stack<string> contextHistory = new();
public ContextManager(InputSystem inputSystem)
{
this.inputSystem = inputSystem;
}
public void PushContextWithHistory(InputContext context)
{
contextHistory.Push(context.Name);
inputSystem.PushContext(context);
}
public void PopToLastContext()
{
if (contextHistory.Count > 1)
{
var currentContext = contextHistory.Pop();
inputSystem.PopContext(currentContext);
}
}
public void PopAllContexts()
{
while (contextHistory.Count > 0)
{
var contextName = contextHistory.Pop();
inputSystem.PopContext(contextName);
}
}
}
Integration Examples
Complete Input Setup
public class GameInputSetup
{
private InputSystem inputSystem;
private GameplayInputContext gameplayContext;
private MenuInputContext menuContext;
public void Initialize(Scene scene)
{
inputSystem = new InputSystem();
inputSystem.Initialize();
SetupGameplayContext(scene);
SetupMenuContext(scene);
SetupGlobalContext();
}
private void SetupGameplayContext(Scene scene)
{
gameplayContext = new GameplayInputContext();
// Player movement
gameplayContext.AddBinding(
InputKey.Keyboard(KeyboardKey.W),
new MovePlayerCommand(scene.Player, Vector3.Forward));
// Combat
gameplayContext.AddBinding(
InputKey.Mouse(MouseButton.Left),
new AttackCommand(scene.Player));
inputSystem.PushContext(gameplayContext);
}
private void SetupMenuContext(Scene scene)
{
menuContext = new MenuInputContext();
// Menu bindings setup...
}
private void SetupGlobalContext()
{
var globalContext = new InputContext("global", 0);
globalContext.AddBinding(
InputKey.Keyboard(KeyboardKey.F11),
new ToggleFullscreenCommand());
globalContext.AddBinding(
InputKey.Keyboard(KeyboardKey.Escape),
new QuitGameCommand());
inputSystem.SetGlobalContext(globalContext);
}
public void Update()
{
inputSystem.Update();
}
public void SwitchToMenu()
{
inputSystem.PushContext(menuContext);
}
public void SwitchToGameplay()
{
inputSystem.PopContext("menu");
}
}
The input system in Popcorning Guinea Pig provides a powerful, flexible foundation for handling all types of user input with clean separation of concerns, priority-based resolution, and extensive customization options.
Further Reading
- PGPSCN Format - Declarative input binding definition
- Game Architecture - Core engine architecture
- UI Components - UI input integration
- Settings Management - Input preferences and keybinding