Adding a repeat-rate for long-held buttons
In the previous post I looked at handling game controller input. The approach was:
- subscribing to NotificationCentre notifications about game controllers being connected or disconnected
- publishing this information with Combine
- when receiving updated controller information, I would bind to their value change handlers, and publish game input events (again with Combine)
One thing that was missing was emitting repeated signals if a button was held down for a longer time. Similar to how your spacebar behaves in a text editing program. For example, if you press and hold it, you might expect this behavior:
<space> ......................... <space> <space> <space> <space>
I.e., you would receive an initial signal, followed by a short delay. After this delay - if you are still holding the space bar - you would start receiving signals in rapid succession.
I played around with Combine a bit and achieved this same effect. Let me walk you through how I did it.
Sidenote: initial prototyping with Swift Playgrounds
As a side note, I wanted to mention that I did the initial prototype in Swift Playgrounds on an iPad. I was very tired and didn't feel like working at the computer. So I lounged on the couch, put on Bob's Burgers in the background, and fired up Swift Playgrounds.
It worked out surprisingly well:
It let me have a UIKit button (to send the raw input signal) and play with Combine in the left pane, to see how to achieve the desired effect.
After about two hours of messing around, I had something that worked, and that could be transplanted to the actual game code.
Step 1 - stream of last-pressed events
The first piece to the puzzle was something I realized I needed very late in the process (during testing).
When using the DPAD on a game controller, if you want to switch directions, you would likely release the currently-pressed direction, and then press a new direction. Thus you would receive these events:
left pressed -> left released -> down pressed -> down released
However, with a thumbstick, you might receive them in a different order:
left pressed -> down pressed -> left released -> down released
This messed up my implementation because while I would keep holding down, the previous state would be released (by the left button), and the stream stopped.
So I added a stream to basically only emit a signal when a different button is pressed. This will later be used to disregard certain signals.
In other words, I am making the assumption that I will only ever care about the most recently pressed button.
Here is an illustration of how this Combine stream works:
Step 2 - ignoring events with the help from step 1
Now that I always know which button I care about, I can combine this stream with the raw input stream to filter out events I don't care about, and simplify later processing:
Note that every event is doubled. This is intentional. In the game controller value change handlers, I send every change to my raw input stream twice. You'll see why in the next step.
Step 3 - aggregating events into panes of 2
I care about different event states:
- button presses
- button releases
- buttons being held continuously
To identify when the state for a button changes, I can't just look at a single state. I need to keep track of the last 2 states. So if two subsequent states are [Released, Pressed] - that is a button press event. If two subsequent states are [Pressed, Released] then a button was released. If two subsequent states are [Pressed, Pressed], then a button is continuously held.
I use the Combine scan() operator to aggregate events and look at them in groups of 2. However, if I don't duplicate events in the raw input stream, I run into a problem.
What will the event stream look like, if a button is pressed and released, and every input event is reported only once?
- raw events: start of stream, down, up
- aggregated: [start of stream, down], [down, up]
If I want to repeat the signal while the key is being held, I am going to use a timer. If I use combineLatest() with this stream, then every timer tick will keep emitting [down, up] - i.e. it will keep reporting that the key has been continuously let go.
Whereas if we duplicate the raw input events:
- raw events: start of stream, down, down, up, up
- aggregated: [start of stream, down], [down, down], [down, up], [up, up]
Now we get a more precise description of what is happening:
- initial signal [start of stream, down] maps cleanly to a button being pressed
- [down, down] maps to the button being held
- [down, up] maps to the button is released
- [up, up] the last item basically means "nothing is happening"
This is why reporting every raw input event twice gives a more precise picture of what's happening.
The overall schema of the third step is this:
When I say - in the diagram - "map into a Pane structure" I just mean I map the tuples of the last two events into a single Swift struct, which semantically maps to a single change event.
The final action is to combine the change event stream with a timer. This is nifty, because:
- if the change event is "button held", we can store a timestamp in our signal
- when the timer emits a tick, the last "button held" signal will be paired with it
- this last signal will contain the timestamp from when the event was emitted, letting us compute the time delta between now and when we started holding the button
So now we have almost everything we need:
- a stream of semantic input events
- continuous output from a timer
- ability to compute the elapsed time from when we started holding a button
The final step is to put this all together.
Step 4 - the final stream
- remove all events where a button is not being pressed
- only emit events without a timestamp (i.e. button press events) or events where a certain delay has elapsed since the associated timestamp. This takes care of the initial delay after the first button press.
- map the resulting stream into something simpler (i.e. remove unnecessary information and simplify the output)
After the initial delay has passed (in this example - 0.3 seconds), then the repeat rate is determined by the timer interval.
And voilà! With this, we can have a neat stream that maps raw input events (button pressed or released) and outputs events with a nice repeat-rate and initial delay.