Game development for Apple platforms

Adding a keyboard input handler

Originally, I live-streamed this session, but - once again - I messed up, and accidentally only recorded the first 10 minutes. So instead of posting a link to the video, I'll just describe what I arrived at.

After wrapping up gamepad input handling, I looked at adding keyboard input. So that I can test the game on a Mac, even if the gamepad is being used on the Apple TV by the kids playing some game.

The overall structure

SKScene gives us two methods to override for handling key input:

func keyDown(with event: NSEvent) 
func keyUp(with event: NSEvent)

I added a KeyboardInputPublisher class (analogous to ControllerInputPublisher and:

Then I created a BaseScene class from which all scenes inherit, and this scene is responsible for combining the input event streams from the keyboard, and the controller, and calling a method for handling the normalized event. Every scene that wants to handle input can override the following function:

func handleInputEvent(_ event: GameInputEvent, from source: GameInputSource)

It looks roughly like this:

class BaseScene : SKScene
{
    let controllerPublisher = ControllerPublisher()
    let controllerInputPublisher: ControllerInputPublisher
    let keyboardInputPublisher = KeyboardInputPublisher()
    
    var cancellables = Set<AnyCancellable>()
    
    required init?(coder aDecoder: NSCoder) {
        self.controllerInputPublisher = ControllerInputPublisher(controllerPublisher: controllerPublisher)
        
        super.init(coder: aDecoder)
        
        keyboardInputPublisher.gameInputEventSubject.merge(with: controllerInputPublisher.gameInputEventSubject).sink { pair in
            self.handleInputEvent(pair.0, from: pair.1)
        }.store(in: &self.cancellables)
    }
    
    func handleInputEvent(_ event: GameInputEvent, from source: GameInputSource)
    {
        
    }
}

#if os(OSX)
extension BaseScene
{
    override func keyDown(with event: NSEvent) {
        self.keyboardInputPublisher.sendRawInput(event: event, down: true)
    }
    
    override func keyUp(with event: NSEvent) {
        self.keyboardInputPublisher.sendRawInput(event: event, down: false)
    }
}
#endif

Mapping NSEvent to GameInputEvent

In the KeyboardInputPublisher I map the events approximately like this:

#if os(OSX)
    public func sendRawInput(event: NSEvent, down: Bool )
    {
        if event.modifierFlags.contains(NSEvent.ModifierFlags.numericPad){
            if let theArrow = event.charactersIgnoringModifiers, let keyChar = theArrow.unicodeScalars.first?.value{
                switch Int(keyChar){
                    case NSUpArrowFunctionKey:
                        self.gameInputEventSubject.send((.up, .keyboard))
                        break
                    // ... insert more cases here
                    default:
                        break
                    }
                }
            } else {
                if let characters = event.characters{
                    for character in characters {
                        guard let asciiValue8 = character.asciiValue else { continue }
                        let converted = Int(UInt(asciiValue8))
                        switch (converted) {
                        case NSDeleteCharacter:
                            self.rawNonRepeatInputSubject.send((.back, down, .keyboard))
                            break
                        case NSBackspaceCharacter:
                            self.rawNonRepeatInputSubject.send((.back, down, .keyboard))
                            break
                        case NSCarriageReturnCharacter:
                            self.rawNonRepeatInputSubject.send((.confirm, down, .keyboard))
                            break
                        case NSEnterCharacter:
                            self.rawNonRepeatInputSubject.send((.confirm, down, .keyboard))
                            break
                        default:
                            break
                        }
                    }
                }
            }
    }
#endif

There are two points to note here:

The raw input subject

The ultimate output goes to gameInputEventSubject. However, the rawNonRepeatInputSubject acts as a simple "middleware" for events that should not be repeated.

When handling the controller input, we had to add repeat-rate behaviour manually. With the keyboard, it is the opposite. You get repeated events by default, and for consistency with controllers, we have to remove this behaviour for some events:

rawNonRepeatInputSubject.removeDuplicates {
    $0.0 == $1.0 && $0.1 == $1.1
}.filter {
    $0.1
}.map { ($0.0, $0.2) }.subscribe(gameInputEventSubject).store(in: &self.cancellables)

In other words - remove duplicates, and keep only "key pressed" events (and ignore "key released").

Different inputs for the same output

The first attempt mapped NSBackspaceCharacter to .back and NSReturnCharacter to .confirm.

This does not work.

The keyboard that came with my iMac sends an NSDeleteCharacter instead of NSBackspaceCharacter when I press backspace. I guess I've been mislabelling the backspace character for my whole (Apple using) life?

And same for the Return character. It actually is registered as the carriage return character.

I assume different layouts (or keyboards) might trigger these other inputs.

Summary and future considerations

At this point, every scene can act on "normalized" input. E.g. .confirm instead of button A or carriage-return character etc. And if we need to update these mappings, we have a central place to do these updates.

In the future, the input will need to be revisited when adding touch and mouse input:

But for now, input handling is good enough.

Next on the docket: working on the gameplay. I'll start with rotating game pieces, then spawning more of them, and then experimenting with it until it feels right.

Addendum (edit): after implementing this, I quickly realized that it is also important to avoid retain cycles in closures.

#combine #controller #input #keyboard