Handling Keyboard Game Control on macOS
Using Swift to implement a solid keyboard input handler for games on macOS.
Implementing game control input with the keyboard might seem trivial at first, but I ran into a few edge cases while I implemented it for the macOS version of the game I am developing.
- Player presses the modifier key
shift
after pressing down, but before releasing, a game control key. - Player simultaneously presses multiple keys that are assigned to the same directional control.
I decided to handle both of those cases to ensure a solid experience for the player.
Normalize Character Casing
Capturing keyboard events using keyDown(with:)
and keyUp(with:)
of NSResponder
gets the job done. The NSEvent
object passed to the methods has the charactersIgnoringModifiers
property that does take care of some normalization to accurately get the correct key for the event.
A gotcha here is that charactersIgnoringModifiers
ignores all modifier keys except shift
, so if the player presses a
then shift
and then releases a
the down method will receive the character a
while the up method will receive the character A
.
In my case I track the characters that are currently pressed so I can update actions as long as the keys in question are pressed down and then stop the actions when the keys are released. Which means in this situation a
gets recorded as pressed, but never recorded as released as A
is passed in for the up event.
The simple solution to deal with that is to normalize all characters passed into the keyboard handler to always be lowercase.
The following example has a method implemented that takes a Character
, lowercases it if it is flagged as uppercase and then returns it. This is executed on each character before passing it on to the class that do the actual handling and tracking of the key presses.
// MARK: Keyboard Event Handling
override func keyDown(with event: NSEvent) {
guard let characters = event.charactersIgnoringModifiers else { return }
for character in characters {
let character = lowercase(character)
// Handle keyDown event for `character` here...
}
}
override func keyUp(with event: NSEvent) {
guard let characters = event.charactersIgnoringModifiers else { return }
for character in characters {
let character = lowercase(character)
// Handle keyUp event for `character` here...
}
}
/// Transform uppercase to lowercase.
///
/// In the case that the player presses and depresses the shift key while using wasd, we always transforms all uppercase input to lowercase to ensure a normalized input.
private func lowercase(_ character: Character) -> Character {
if character.isUppercase {
return Character(character.lowercased())
}
return character
}
Clamp Directional Control
Player movement is handled by tracking the direction in a vector property currentDirection
. When a key is pressed the key's direction value is added to currentDirection
and when the key is released the value is subtracted. This allows the player to combine keys for diagonal movements.
If the up key has the value (dx: 0.0, dy: 1.0)
currentDirection
gets 1.0
added to the Y direction when the up key is pressed, and then the same value subtracted when the key is released bringing the currentDirection
Y value back to 0.0
.
This all seems fine and dandy so far, but what about the scenario when the player can use multiple keys for the same input? In this case the player can use either wasd
or the cursor keys to move around in the game.
The player presses w
to move up and 1.0
gets added to currentDirection.dy
. Without releasing w
the player now also presses the up cursor
key. Another 1.0
gets added to currentDirection.dy
and we are now at 2.0
and the player has found a way to move in double speed.
The simplest way I found to handle this was to allow and record the result of multiple inputs and store 2.0
for currentDirection.dy
. This makes additional calculations correct when the keys are released. Then, before passing the vector on to the part of the game that handles movements I clamp the value to stay within the range -1.0...1.0
.
In the following example, a clamp(direction:)
method has been implemented that uses Swift's closed range operator to clamp the vector to remain within the limits before passing it on to the player entity.
/// The vector used to keep track of direction.
private var currentDirection = CGVector.zero
// MARK: - Keyboard Logic
func handleDownKey(forCharacter character: Character) {
// Key pressed tracking code...
// Get the directional vector for the character.
if let direction = direction(for: character) {
// Add directional vector to currentDirection.
currentDirection += direction
let clampedDirection = clamp(direction: currentDirection)
// Pass clamped direction to player entity...
}
}
func handleUpKey(forCharacter character: Character) {
// Key released tracking code...
// Get the directional vector for the character.
if let direction = direction(for: character) {
// Subtract directional vector to currentDirection.
currentDirection -= direction
let clampedDirection = clamp(direction: currentDirection)
// Pass clamped direction to player entity...
}
}
// MARK: Convenience
/// Clamps a CGVector to ensure that the direction stays within the range -1.0 to 1.0.
///
/// As the direction can be controlled with either `wasd` or the `arrow keys` a player can for instance press both the `up arrow` and `w` at the same time. That would result in a directional up vector of 2.0 instead of 1.0. To prevent that from happening we store the unclamped valued in the `currentDirection` variable to ensure that calculations like subtract on the released key event is calculated correctly, while we use `clamp(direction:)` on the value we pass on to the delegate.
private func clamp(direction: CGVector) -> CGVector {
let limits = CGFloat(-1.0) ... CGFloat(1.0)
return CGVector(
dx: min(max(direction.dx, limits.lowerBound), limits.upperBound),
dy: min(max(direction.dy, limits.lowerBound), limits.upperBound))
}
Conclusion
As demonstrated there are edge cases when it comes to handling keyboard input. Dedicating some time to handle them adds that extra bit of polish to provide a solid player experience.