Handling Inputs in Phaser 3: Part 4: Juggling Axes
Previously, we spiced up our input system with derived inputs, allowing for powerful input customization to occur without affecting the update logic, effectively decoupling our input from our game logic.
On this episode, we’re finally tackling axis support. The infrastructure is all there; this time it’s going to be quite straight-forward.
What’s an Axis?
While buttons and keys return a binary input, pressed or not pressed, an input axis returns a number, usually within a fixed range. A thumbstick on your controller returns two axes, both with the range in [-1, 1].1 An Xbox controller’s trigger returns a range in [0, 1]. These axis values give you more granular information about the controller’s input state.
Slotting in the Axes
In a previous post, we discussed whether it made sense to unify keyboard and controller button inputs, or leave them as separate concepts that the consumer has to handle.
A similar question is worth asking for axes. Does it make sense to treat all axes as buttons or all buttons as axes?
The answer, in my mind, is a clear no.2
If you’re in agreement, feel free to skip the next sections.
Everything’s a Button
In this scenario, we would have some threshold at which an axis would go from unpressed to pressed.
- Buttons work as expected. Great!
- Axes would lose the capacity to handle partial inputs—squashing a number to a binary input necessarily loses precision.
- It gets more complicated to have dead zones.3 They’d need to be hard-coded in at KHInputKey creation, making it complicated to adjust for different dead zones.
This manages to make axes into nothing more than more fiddly buttons. If that’s truly fine for the game you’re using, converting everything to a button initially will be fine, but could cause you some headaches later.
Everything’s an Axis
In this scenario, we would have pressed buttons report a value of 1, and unpressed buttons report a value of 0.
- Axes work as expected. Spectacular!
- If you knew an input was a button, you could handle it correctly.
- Every time you want to use a button, you’d have to do an unnecessary numeric comparison.
JustDown
gets complicated. It’d need to be there for buttons, but what does it mean for an axis?
This approach strikes me as a bit messy but otherwise harmless, except for the lack of a concrete justDown
implementation, like the other two have. We’d need to wrap an axis to see if justDown
was triggered, and then we’re right back where we started.
Everything in Moderation
In this scenario, buttons would act like buttons and axes would act like axes. A trail-blazing approach.
- Buttons work as expected. Fantastic!
- Axes also work as expected. Unprecedented!
JustDown
support only where it makes sense.- Ability to convert buttons into axes and axes into buttons as needed using derived inputs.
Conclusions
Ultimately, the two inputs are distinct enough that it makes little sense to attempt to combine them.
Taking Axes to the Code
First of all, let’s add KHInputKey
’s sibling, KHInputAxis
. We’ll want a few different fields: current value and last value (why not?).
class KHInputAxis {
protected value: number;
protected lastValue: number;
constructor() {
this.value = 0;
}
/**
* Updates the axis value to the value passed in. Returns whether or not
* the value changed.
*/
update(value: number): boolean {
let changed = this.value != value;
this.lastValue = this.value;
this.value = value;
return changed;
}
getValue(): number {
return this.value;
}
getLastValue(): number {
return this.lastValue;
}
changed(): boolean {
return this.value != this.lastValue;
}
}
change()
is also surfaced as a useful helper method. While I have a use for it in mind,4 it’s also easy to imagine a derived input making use of it.
Speaking of derived input, what would a derived axis look like?
abstract class KHInputAxisDerived extends KHInputAxis {
constructor(inputSet: KHInputSet) {
super();
if (inputSet) {
inputSet.addInput(this);
}
}
abstract updateDerivedInput(now: number, delta: number): void;
}
Fairly straight-forward. The eagle-eyed of you with a solid memory will notice that this is functionally identical to KHInputKeyDerived
class. Aside from having no good reason for it not to be identical, it also helps us avoid a pitfall.
Remember KHInputSet
, which we were using to ensure the creation order consistency of derived inputs? Assume it had two arrays: KHInputKeyDerived[]
and KHInputAxisDerived[]
. Imagine that we updated these, using the following code:
for (let i = 0; i < this.inputSet.inputs.length; i++) {
this.inputs[i].updateDerivedInput(now, delta);
}
for (let i = 0; i < this.inputSet.axes.length; i++) {
this.axes[i].updateDerivedInput(now, delta);
}
Can you spot the issue? That’s right!5
If we have a derived input key that depended on a derived axis, it would get stale input due to all of the derived keys being updated before all the derived axes. This is the biggest problem with the derived input system: it invites you to accidentally reorder the inputs.
Instead, we plop both derived keys and derived axes into a shared array of KHIDerivedInput
, an interface that simply requires that updateDerivedInput(now: number, delta: number): void
be defined by the class. Since they’re both added to the array when initialized, we guarantee that the ones initialized first are updated first.
Now that we have the ability to set up axes and derived axes, let’s examine the classes that establish the base axis inputs.
The Keyboard Provider
This one’s easy. Keyboards don’t have any axes,6 so it’s already done. Great!
The Controller Provider
Controllers tend to have axes.7 Fortunate, since otherwise I wouldn’t have much to say in this blog post.8
We don’t want to try to unify buttons and axes into one concept. They serve two very different functions, so it makes sense to surface the difference to the consumer.
Let’s make some additions to KHInputProviderController
:9
enum KHPadAxis {
LeftStickX,
LeftStickY,
}
class KHInputProviderController {
...
axes: Map<KHPadAxis, KHInputAxis>;
constructor(scene: Phaser.Scene, gamePadNumber: number) {
...
this.axis = new Map();
this.axis.set(PadAxis.LeftStickX, new KHInputAxis());
this.axis.set(PadAxis.LeftStickY, new KHInputAxis());
}
...
update(now: number, delta: number) {
...
this._updateAxis(PadAxis.LeftStickX, gamePad.leftStick.x);
this._updateAxis(PadAxis.LeftStickY, gamePad.leftStick.y);
}
private _updateAxis(axis: PadAxis, value: number) {
this.axis.get(axis).update(value);
}
getAxis(axis: PadAxis): KHInputAxis {
return this.axes.get(axis);
}
}
Nothing should come as a surprise there.
Implementing the axis support in FBDInputHandler
is left as an exercise for the reader.10
It shouldn’t be too complicated: add an extra input in the constructor, add a getValue(key: string): number
, and you’re off to the races. Besides, I didn’t end up using KHInputHandler
for axes anyway, and I have no desire to reimplement it. Besides, I post too many lengthy code snippets as is.11
Transmogrifying Axes
Go back in time five minutes or so, to when you were skipping over where I talked about converting all keys to axes or axes to keys. What if we had wanted to take one of these approaches. Well, the good news is that we could if we want.12 That’s the power of derived inputs.
Everything’s a Button
Let’s do it. Sure, we settled on the previous approach, but I want everything to be a button anyway! Yeah, okay, I’ll leave the previous code—I don’t want to spend time rewriting it.
/**
* Converts an axis to a key based on a given threshold. If threshold is
* positive, it will check if the axis value is >= threshold. If the threshold
* is negative, it will check that the axis value is <= threshold.
*/
class KHInputKeyKeyFromAxis extends KHInputKeyDerived {
private source: KHInputAxis;
private threshold: number;
constructor(inputSet: KHInputSet, source: KHInputAxis,
threshold: number) {
super(inputSet);
this.source = source;
this.threshold = threshold;
}
updateDerivedInput(_now: number, _delta: number) {
if (this.threshold < 0) {
this.update(this.source.getValue() <= this.threshold)
} else {
this.update(this.source.getValue() >= this.threshold)
}
}
}
The dirty secret is that sometimes an axis should be two keys. The X component of the left thumbstick goes from -1 to 1, so we’d instantiate them like so:
const axis = controller.getAxis(KHPadAxis.LeftStickX);
const threshold = 0.5;
const left = KHInputKeyKeyFromAxis(inputSet, axis, -threshold);
const right = KHInputKeyKeyFromAxis(inputSet, axis, threshold);
Voila, we’ve got our keys and don’t need to use any dirty axes anymore.13
Everything’s an Axis
Forget making everything buttons. That was a silly idea. You lose precision that way! Unacceptable. Let’s make everything an axis. Good thing we didn’t change the underlying code.
class KHInputAxisFromKeys extends KHInputAxisDerived {
private positiveKey: KHInputKey;
private negativeKey: KHInputKey;
constructor(inputSet: KHInputSet, positiveKey: KHInputKey,
negativeKey: KHInputKey = null) {
super(inputSet);
this.positiveKey = positiveKey;
this.negativeKey = negativeKey;
}
updateDerivedInput(now: number, delta: number) {
this.update((this.positiveKey?.isDown() ? 1 : 0)
+ (this.negativeKey?.isDown() ? -1 : 0));
}
}
As the left thumbstick becomes two keys, two keys become an axis.
const left = controller.getButton(KHPadInput.Left);
const right = controller.getButton(KHPadInput.Right);
const leftStickX = KHInputAxisFromKeys(inputSet, right, left);
For this axis, you don’t even need to apply a dead zone. Hey, maybe keys are better than axes after all!
Next post, mouse inputs and how they break everything.14
-
When expressing ranges, square brackets, “[]”, indicate that the range includes the numbers at the endpoints of the range. Parentheses, “()”, indicate that the range excludes the numbers at the endpoints. So if the range was [0, 5), it would mean that the range includes zero and all the numbers up to, but not including, five. It also would be equivalent to 0 ≤ x < 5. ↩︎
-
You may have noticed that I treated L1/R1 as buttons, even though they were exposed as axes by Phaser. There are cases where I find it beneficial to coerce the value to another type, but I don’t think it’s useful to do so as a general rule. ↩︎
-
Dead zones ignore inputs at less than a certain value to prevent a controller from accidentally triggering input. Controller thumbsticks aren’t perfectly centered and wear down over time, causing the stick to center at a non-zero point. There will be a post later on dead zones. ↩︎
-
Two actually. It will be useful when detecting the last used input provider, and it is useful for handling mouse movement, which is a differently-behaving axis. ↩︎
-
You were right, weren’t you? ↩︎
-
Mine doesn’t, at least. I’m sure there’s one somewhere that does, and I’m not counting that little nub on some laptops—I’m pretty sure that comes in as mouse input. ↩︎
-
Not all of them—my SNES knockoff USB controller doesn’t have any sticks, but the D-pad input comes in as both D-pad input and left stick input. ↩︎
-
It probably would’ve just been a bunch of pictures of actual axes. Here’s one: ↩︎
-
Simplified, as always. ↩︎
-
Don’t worry, I also hate it when an article says that. It’s often the part that I actually want the implementation for. ↩︎
-
I would add more excuses, but I believe that three is far more than you should ever put down in a blog. ↩︎
-
And there are good reasons to do this, some of the time. For instance, having the left stick be a button is useful for traversing menus (you probably want to make it a repeating button too), and having WASD total up to the left stick is useful for top-down games. ↩︎
-
You should always use a dead zone when handling axes (Phaser gives a primitive one by default). In this case, the threshold is serving that purpose. It’s functionally the axial dead zone approach, and has some limitations. See the upcoming dead zone post for more details. ↩︎
-
Well, almost everything. ↩︎