The hardware is done and the Nerf gun is assembled, so all that’s left is to write the code to drive the controller!
This post is part of a series on creating a custom controller for Overwatch using a Nerf revolver. Check out the project page here.
Programming Principles
Before talking about the code itself, I need to cover two overall things about how the program is going to be written.
IDE Limitations
I’m going to be programming the controller using the back-to-basics Arduino IDE. Because of how the IDE is set up, I can’t create classes or structs for each part of the controller inside of main .ino
file. If I wanted to create classes for different parts of the code I would need to include separate header and implementation files, which seems excessive for something relatively simple like this.
Interrupts vs. Polling
This is a classic question with a fairly definitive answer: when possible, interrupts will always be preferred over polling. But I’m going to use polling for this controller.
I have a few reasons for this, not least of which is that due to my routing limitations the PCB was not made with inputs mapped to interrupt-capable pins. Although even if it was, both the capacitive sensor and the LED code disable interrupts and I’m not sure if the keyboard / mouse libraries would process their outputs enough to allow the I2C interrupts to work as designed.
Translated to English, that means that so long as polling works it’s acceptable. Everything is at a human time scale so I shouldn’t need to worry about missing updates. The only exception to this is the rotary encoder, but since I only care about whether it’s turned and not how far it’s turned (within reason) this is not a concern.
The Loop
const long IMU_UpdateRate = 5; long timestamp; long lastUpdate; void loop() { timestamp = millis(); if (timestamp >= lastUpdate + IMU_UpdateRate) { lastUpdate = timestamp; handleIMU(); } buttonInputs(); cylinderReload(); updateLED(); }
The Arduino’s loop continuously repeats the same code. From here each part of the controller is sampled and the outputs are sent onward to the computer.
The code evaluates the buttons, rotary encoder, and LED as quickly as possible. The IMU is evaluated on a regular interval (5 ms) based on the controller’s hardware timer. This is partly because the I2C request code is blocking and slow (approx. 500 microseconds per call at max bus speed) and because it’s important for the aiming to be consistent regardless of the execution speed for the rest of the code.
Pin Definitions
Before even launching into the loop, the first thing to do is set up the pins. These are defined by the circuit board:
// Pin Definitions (do not change) const uint8_t HammerButtonPin = 16; const uint8_t TriggerButtonPin = 10; const uint8_t EncoderAPin = 6; const uint8_t EncoderBPin = 7; const uint8_t LedPin = A2; const uint8_t CapSourcePin = A3; const uint8_t CapSensorPin = 14; void setup() { pinMode(HammerButton, INPUT_PULLUP); pinMode(TriggerButton, INPUT_PULLUP); }
These are taken straight from the schematic. Both ‘A2’ and ‘A3′ are preprocessor macros for their respective pins depending on the board (’20’ and ’21’ are the digital numbers), but leaving them as macros makes it easier to check against the PCB layout.
The pins for both buttons need to be declared as inputs with the internal pull-ups enabled to avoid floating. All of the other pins are going to be handled by their respective libraries.
Basic Inputs
The controller’s buttons and rotary encoder are the simplest parts to deal with, so let’s start with them first.
Buttons
boolean triggerActive = 0; boolean hammerActive = 0; void buttonInputs() { boolean triggerRead = digitalRead(TriggerButtonPin); boolean hammerRead = digitalRead(HammerButtonPin); if(triggerRead == 0) { if(triggerActive == 0) { Mouse.press(); triggerActive = 1; } } else if(triggerActive == 1) { Mouse.release(); triggerActive = 0; } if(hammerRead == 0) { if(hammerActive == 0) { Mouse.press(MOUSE_RIGHT); hammerActive = 1; } } else if(hammerActive == 1) { Mouse.release(MOUSE_RIGHT); hammerActive = 0; } }
The states of both buttons are read and then parsed with the same logic. I’m using a static variable to keep track of the previous button state, so holding down the button doesn’t spam inputs.
If a button is pressed for the first time it sends the command to ‘press’ its respective key (mouse left or mouse right). If the button is released and was previously pressed, it sends the command to ‘release’ its respective key. Fairly straight-forward.
These are dealt with separately rather than in an array because the trigger drives more commands that don’t relate to shooting. I’ll talk about those as we come to them.
Cylinder Reloading
The rotary encoder being used to detect the motion of the cylinder is a cheap Chinese part whose outputs are quite noisy. Rather than writing my own code I’m going to use a library called Encoder by Paul Stoffregen.
const int16_t EncoderThreshold = 8; // Threshold to reload (4 per detent) long lastReload; Encoder cylinder(EncoderAPin, EncoderBPin); void setup(){ ... cylinder.write(0); // Zero the encoder position } void cylinderReload(){ int16_t newPosition = cylinder.read(); if(newPosition >= EncoderThreshold || newPosition <= -EncoderThreshold){ Keyboard.press('r'); Keyboard.release('r'); lastReload = timestamp; cylinder.write(0); // Zero the encoder position } }
The encoder’s pins are polled for changes during every loop (one pin is not interrupt-capable), and the read
function returns the accumulated change. Each detent on my encoder is counted as 4 positions, with 20 detents per 360°. The threshold to trigger a reload is set to 8, which is 2 detents or 36° of change for the cylinder in either direction.
When the encoder reaches its threshold it presses the reload key (‘r’) and zeroes the encoder’s position. I’m not worrying about debouncing for overspin since that is handled by the game (i.e. reloading twice quickly doesn’t cancel the animation), but I am recording the activation time in order to set the LED.
Capacitive Sensing
The capacitive input uses the CapacitiveSensor library, once again by Paul Stoffregen.
const int16_t CapThreshold = 75; // Threshold to 'pressed' (no units) CapacitiveSensor capButton = CapacitiveSensor(CapSourcePin, CapSensorPin); void setup(){ capButton.set_CS_AutocaL_Millis(0xFFFFFFFF); // turn off autocalibration capButton.set_CS_Timeout_Millis(10); } void handleIMU() { ... // If capacitive button is triggered, don't aim if(capRead() == true){ currentLED = pauseColor; } else{ aiming(gz, gx); } ... } boolean capRead(){ const int16_t nSamples = 15; int16_t reading = capButton.capacitiveSensor(nSamples); // Hammer makes reading inaccurate if(reading >= CapThreshold && !hammerActive){ return true; } return false; } void buttonInputs() { ... if(triggerRead == 0) { ... if(!ultActive){ capButton.reset_CS_AutoCal(); } } ... } void cylinderReload(){ ... if(newPosition >= EncoderThreshold || newPosition <= -EncoderThreshold){ ... capButton.reset_CS_AutoCal(); } }
The handleIMU
function calls capRead
, which reads the capacitive sensor with 15 samples and returns a unit-less variable representing the relative capacitance. If this variable is greater than 75 (arbitrary limit) the sensor is seen as “active”.
One thing I noticed while testing is that when using my left hand to pull the hammer back it would trigger the capacitive sensor, so the function is configured to not return a ‘true’ value if the hammer button is currently active.
The sample count and timeout values were mostly trial-and-error, trying to keep the polling time low while still keeping a clear distinction between a “pressed” and “unpressed” state (thumb on top of the sensor or clear from it).
Custom Calibration
The other thing I noticed is that because the sensor picks up stray capacitance, everything from the weather to the time of day and the cycle of the moon will influence it. When I set static values it worked fine but occasionally I would pick it up and the baseline level would be different, so I had to find a way to set the threshold values on the fly.
The timer-based autocalibration was unreliable because would reset the calibration when I was holding the button. The solution I came up with was to activate the library’s auto-calibrate feature manually every time the trigger was pulled or the cylinder was spun. The trigger is something that shouldn’t be getting pulled when aim inputs are disabled, and the rotary encoder seems to interfere with the sensor (induction on the PCB?). A nice perk of the autocalibration is that it works off of the lowest-sensed value, so if I do accidentally set the calibration when I’m “pressing” the capacitive button it will re-calibrate as soon as I let go and its value drifts below the zero’d level.
Running the auto-calibration this way means I need to pull the trigger after the gun starts up before the capacitive sensor will work properly. A small inconvenience, but an inconvenience nonetheless.
As an aside, the wires for the capacitive sensor’s antenna run through the hand grip, which means that I can detect whether someone is holding the controller or not. I don’t do anything with that at the moment, but it’s something to think about for the future.
IMU Settings and Data
Now that the basic stuff is done, let’s jump into the most complicated part of the code: dealing with the MPU-6050 and processing its physics data.
Initializing the MPU
In the setup
function the program needs to initialize the I2C bus, wake up the MPU, and set the scale for the gyroscope.
const uint8_t MPU_addr = 0x68; // I2C address for the MPU const uint8_t FS_SEL = 1; // Gyroscope range select, 500°/s void mpuStart(){ Wire.begin(); TWBR = 12; // 400 kHz bus speed Wire.beginTransmission(MPU_addr); Wire.write(0x6B); // PWR_MGMT_1 register Wire.write(1); // use GyX oscillator Wire.endTransmission(false); // Keep bus active Wire.beginTransmission(MPU_addr); Wire.write(0x1B); // GYRO_CONFIG registers Wire.write(FS_SEL << 3); // Gyroscope scale Wire.endTransmission(true); }
The first code block with Wire.begin()
starts the Arduino’s I2C bus and sets the clock to 400 kHz, which is the fastest speed supported by the MPU.
The second block “wakes” the MPU and sets its oscillator reference. ‘0’ would use the internal 8 MHz oscillator, while ‘1’ sets it to use the gyroscope’s X axis. This is recommended by the manufacturer for “improved stability”. I don’t honestly know the practical difference between using the X, Y, or Z axis as a clock reference, but the X axis seems to work well-enough.
The third block sets the scale for the gyroscope. There are four options: 250, 500, 1000, and 2000 degrees per second. With how quickly I’m moving the gun to aim I found that I would regularly max out the scale at 250 °/s, so I bumped it up to 500. I still hit the limit occasionally, but it’s far less frequent. If I bumped the scale up to 1000 I’m sure I’d never hit the limit, but I fear that the aiming would be significantly less accurate.
The information about the MPU’s registers can be found in an appendix to the datasheet.
Reading MPU Data
Every 5 milliseconds data is pulled from the MPU and processed.
void handleIMU() { int16_t ax, ay, az, gx, gy, gz; readMPU(ax, ay, az, gx, gy, gz); ... } void readMPU(int16_t &aX, int16_t &aY, int16_t &aZ, int16_t &gX, int16_t &gY, int16_t &gZ){ ... int16_t MPU_temperature; int16_t *IMU_Data[7] = {&aX, &aY, &aZ, &MPU_temperature, &gX, &gY, &gZ}; Wire.beginTransmission(MPU_addr); Wire.write(0x3B); // starting with register 0x3B (ACCEL_XOUT_H) Wire.endTransmission(false); Wire.requestFrom(MPU_addr,(uint8_t) 14, (uint8_t) true); // request a total of 14 registers for(int i = 0; i < 7; i++){ *IMU_Data[i] = Wire.read() << 8 | Wire.read(); // A-XYZ, TMP, and G-XYZ } ... }
The loop calls the handleIMU
function, which calls readMPU
to get the data from I2C. The Arduino Pro Micro, acting as a master, requests 14 bytes from the MPU starting with the X axis of the accelerometer. The MPU then returns, sequentially, the values for the accelerometer (XYZ), the temperature, and the gyroscope (XYZ). The bytes are combined into signed 16-bit integers using a bitwise ‘OR’. Variables to store each value are passed using references.
Note: Although the temperature value isn’t needed, it would take more time to make a second request skipping over that register than it takes to discard the data.
Reorienting
The outputs for the MPU, as mounted inside of the Nerf gun, do not reflect what you would normally think the coordinate system should be.
When held in your hand, a regular right-handed coordinate system for the gun would have the X axis protruding from its right face, the Y axis going forward along its barrel, and the Z axis extending upwards from its top. Indeed when mounted flat, this is how the MPU organizes its outputs.
Since the IMU is mounted sideways inside of the gun, the coordinate system is twisted 90° counter-clockwise about the Y axis. This makes it so that the Z axis extends out the left face of the gun and the X axis extends vertically (the Y axis is unchanged). The photos above show the differences between the two arrangements, using a die to show the axes.
This isn’t necessarily a problem, but it does make the readings less intuitive to work with. So I’m going to fix it.
void reorientMPU(int16_t &aX, int16_t &aY, int16_t &aZ, int16_t &gX, int16_t &gY, int16_t &gZ){ // Reorient inputs from the position of the MPU to the expected coordinate system. // McCree Hammershot: Twist board +90° about Y. Z = X, X = -Z, Y doesn't change. int16_t tempAxis; // Reorient accelerometer tempAxis = aZ; aZ = aX; aX = ~tempAxis; // Reorient gyroscope tempAxis = gZ; gZ = gX; gX = ~tempAxis; }
Z switches places with X, and X flips its sign as the axis is now pointing in the opposite direction. There is a tiny performance hit here, but nothing significant.
Note that I’m using a bitwise NOT (‘~’) to flip the number, as using ‘-‘ will cause an overflow if the number is at its max negative value. I don’t have to worry about the two zeroes issue because the flipped ‘0’ output of ‘-1’ is zeroed in the next step.
Calibrating
The last step is to calibrate each gyroscope axis and filter insignificant inputs.
const int16_t GyroZeroThreshold = 200 / (1 << FS_Sel); // Level below which to null inputs in 250 DPS int16_t units void readMPU(int16_t &aX, int16_t &aY, int16_t &aZ, int16_t &gX, int16_t &gY, int16_t &gZ){ const int16_t GyroCalX = -60; const int16_t GyroCalY = -50; const int16_t GyroCalZ = 18; const int16_t GyroCalibration[3] = { GyroCalX / (1 << FS_Sel), GyroCalY / (1 << FS_Sel), GyroCalZ / (1 << FS_Sel), }; ... // Calibrate gyro axes and check for overflow for(int i = 4; i < 7; i++){ int32_t gyroTemp = (int32_t) *IMU_Data[i] + (int32_t) GyroCalibration[i - 4]; if(gyroTemp > 32767){ *IMU_Data[i] = 32767; } else if(gyroTemp < -32768){ *IMU_Data[i] = -32768; } else{ *IMU_Data[i] += GyroCalibration[i - 4]; if(abs(*IMU_Data[i]) < GyroZeroThreshold){ *IMU_Data[i] = 0; } } } }
The values returned from the gyroscope are in the range of (and stored in) a signed 16-bit integer. Adding or subtracting anything runs the risk of an integer overflow, so the calibration values are added to a 32-bit version of the number and if it’s outside of the range the variable is set to its maxed out value. During these checks I also set insignificant inputs to ‘0’ if the absolute value is below a given limit (I’m using ‘200’ at the moment).
Both the calibration value and the zero threshold are in int16_t units at 250 °/s, and are scaled with the range setting on the MPU.
Aiming
With the gyroscopic data in hand, let’s start applying it to stuff! First up is the mouse aiming.
The aiming function is called from handleIMU
and is passed the gyroscopic rotations for the Z axis and the X axis, which correspond to movement along the mouse’s X and Y, respectively.
Capacitive and Range Checks
As I mentioned above, before calling the aiming
function the program checks if the capacitive sensor is activated. If the player has their thumb over the sensor no mouse inputs are going to be performed, so why calculate them?
void handleIMU() { ... // If capacitive button is triggered, don't aim if(capRead() == true){ currentLED = pauseColor; } else{ aiming(gz, gx); } ... } void aiming(int16_t gyX, int16_t gyY){ ... if(gyX == 0 && gyY == 0){ return; // No mouse movement } ... }
If the capacitive sensor is not activated, the aiming function is called and the first thing it does is check that both gyroscopic inputs are non-zero. If either input is non-zero the function will proceed and the mouse will be moved.
Gyroscope Rates
At this point it would be enough to simply divide the gyroscope value by some arbitrary amount and send those values to the Mouse
class, but I’m going to make this a bit more elegant. The first step is to convert the gyroscope values into something more useful.
const int16_t Gyro_FullScaleRange[4] = {250, 500, 1000, 2000}; // Gyroscope ranges (in degrees per second) const float IMU_AngleChange = (float) Gyro_FullScaleRange[FS_Sel] / (1000.0 / (float) IMU_UpdateRate); // Convert °/s to degree change per time read const float IMU_Scale = IMU_AngleChange / 32768.0; // Convert angular change to int16_t scale
As I’ve said before, the gyroscope outputs a 16-bit signed integer representing a degree per second value along a +/- range depending on how the MPU’s registers are set. For example, at 500 degrees per second a value of 32,767 (215 – 1) represents a reading of +500 degrees per second, a value of 16,383 (214 – 1) represents +250 degrees per second, etc.
Now, I’m reading the gyroscope outputs at a regular rate of every 5 milliseconds. 1000 milliseconds divided by 5 gives me 200 Hz, which is the number of samples I’m taking per second. Dividing the scale range by the number of samples gives me the maximum degree per second change in that time frame. Put another way: if the gyroscope senses 500 °/s of change over a timeframe of 5 milliseconds, you can reasonably assume that the gyroscope itself moved 2.5° along that axis.
Lastly, I divided that maximum degree per second change by the number of values on either side of a 16-bit signed integer (215). This gives me a scale factor to multiply against the output from the gyroscope.
float degreeChange = IMU_Scale * (float) gyX;
Multiplying the IMU_Scale
variable by the output of the gyroscope then gives me the angular change of the IMU in degrees. Neat, huh?
Overwatch Mouse Calibration
But wait, there’s more! Now that I can calculate the precise angular change, I can convert that value into mouse “ticks” to send to Overwatch and get my character to aim 1:1 with my movements.
const float OverwatchSens = 10.0; // User mouse sensitivity in Overwatch const float AimSpeedFactor = 1.25; // Factor to multiply aimspeed by const float OverwatchTPD = 54550.0 / 360.0; // Ticks per degree in Overwatch, at 1.0 sens. const float OverwatchConversion = (OverwatchTPD * AimSpeedFactor / OverwatchSens) * IMU_Scale;
First, I ran a few tests in Overwatch to figure-out how many mouse ‘ticks’ it takes per degree of change. I went into the practice range and found a corner with a sharp, defined edge. I placed my cursor at a repeatable point, and then using the move
function of the Arduino’s Mouse
library I moved the mouse until the character had completed a 360° turn and recorded the value. From these tests, Overwatch requires approximately 54550 mouse ticks at 1.0 sensitivity to rotate the character model 360°. Changing the in-game sensitivity adjusts the number of ticks required, as expected.
54550 divided by 360 gives me approximately 151.5 ticks per degree. Dividing this by my Overwatch sensitivity gives me the number of mouse ticks, at that sensitivity, per degree. Multiplying that value by that previous IMU_Scale
value gives me my magical conversion factor for converting the 16-bit signed integer values from the IMU into 1:1 degree changes in Overwatch.
Note: I‘m also multiplying by another “aimspeed” variable to make things not quite 1:1. While 1:1 movement is a good trick to have in my pocket, it feels too slow for actually playing the game.
Since these values are all compile-time constants, the end result only requires a single float value for the conversion which keeps things efficient.
An Aside: Pixel Skipping
Now seems like as good of a time as ever to talk about pixel-skipping.
Pixel-skipping is often talked about regarding competitive games where players are trying to get the best performance from their mouse setup. There are two factors here: the hardware sensitivity (mouse DPI) and the software sensitivity. At a certain point, the software gain is so high that a single input from the mouse will cause the player’s in-game cursor to “skip” over pixels (hence the name), impeding their aim. The solution is to use a higher hardware sensitivity and a lower software sensitivity so that small hardware inputs still have a noticeable impact on the game’s cursor. This is a balancing act, as hardware can only be so sensitive before noise becomes a limiting factor.
For the Nerf controller, this means that in an ideal world I would set the Overwatch sensitivity very low (1.0 or lower) and boost that conversion factor for the gyroscope data. But there is more at play than just the aiming sensitivity in game: the controller is also used to navigate the game’s menu, which does not obey the in-game sensitivity setting. Keeping the aiming sensitivity between 5.0 and 10.0 seems to be the sweet spot where the aiming is fluid but the menus are still navigable.
Aim Calculation
With the conversion value calculated, all that’s left is to do the aiming calculation itself:
int16_t * xyInputs[2] = {&gyX, &gyY}; static float xyRemainder[2]; float xyScaled[2]; for(int i = 0; i < 2; i++){ if(*xyInputs[i] != 0){ xyScaled[i] = OverwatchConversion * (float) ~*xyInputs[i]; float remainderTemp = xyScaled[i] - (int32_t) xyScaled[i]; if(abs(remainderTemp) >= 0.1){ xyRemainder[i] += remainderTemp; } if(xyRemainder[i] >= 1){ xyScaled[i] += 1; xyRemainder[i]--; } else if(xyRemainder[i] <= -1){ xyScaled[i] -= 1; xyRemainder[i]++; } } } Mouse.move((int) xyScaled[0], (int) xyScaled[1]);
First, the input is checked to be non-zero. No use performing an aim calculation if there is no input.
Next, the gyroscope readings for both axes are flipped to match the coordinate system for the mouse. Following the right-hand rule, a twist along the Z axis (yaw) to the left is positive and a twist to the right is negative. This is the opposite of the screen’s coordinates, where a positive value along X increments to the right.
(It’s worth noting that this is the argument against reorienting the outputs from the MPU. Since the mouse’s ‘Y’ input (formerly the Z axis, now X) needs to be flipped when it’s reoriented, it is actually flipped twice. Although, again, the performance impact is negligible.)
The flipped gyroscope outputs are multiplied by the conversion factor and stored in a float. The Mouse
class only takes integer inputs, so the decimal from the float is stored in a “remainder” variable for the next cycle. If the remainder is greater than 1 it’s added onto the output from the next sample. Small remainders (< 0.1) are discarded.
At the end of the function the scaled gyroscope values are converted to integers and sent to the computer as mouse inputs.
These calculations use floats to keep everything precise, but note that there is a way to do this entirely with integers.
const int16_t OverwatchConversionInt = (int16_t) (1.0 / OverwatchConversion); int16_t xyScaled[2]; xyScaled[i] = ~*xyInputs[i] / OverwatchConversionInt;
“1” divided by the conversion factor gives an integer for the number of 16-bit values per 1 mouse tick. Flipping the calculation to divide the gyroscope output by the conversion factor gives the same aiming result without any runtime floats. However, dropping the decimal significantly reduces the accuracy. When comparing the integrated results of the float vs integer calculation they diverged significantly in less than a second and never recovered. I’m going to stick with the floating point math for now.
Edit: This original code had two bugs in it that caused some “jittering” in the aim output. I’ve fixed these bugs in the above code, and if you’re curious you can find more information on them in the next post.
IMU Driven Abilities
The accelerometer and gyroscope values from the MPU control two other abilities: the flashbang and the ultimate.
Ultimate Ability
McCree’s ultimate ability is “Deadeye” (colloquially known as “High Noon”), where he mimics a classic old western showdown. He sets his revolver in its holster, sun at his back, before quickly drawing and killing all of the enemies in his sight. I want the controller to trigger my ultimate ability when I mimic his animation, namely putting the controller at my side as if it was in a holster.
void ultimate(int16_t ax, int16_t ay, int16_t az){ const int16_t Gravity = -16384; // Value of -1g on the +2/-2 scale const int16_t yAngleTolerance = 1000; // Tolerance on the Y-axis angle check (front to back) const int16_t xAngleTolerance = 2000; // Tolerance on the X-axis angle check (left to right) const int16_t zAngleTolerance = 4000; // Tolerance on the Z-axis angle check (top to bottom) const int16_t xNeutralPoint = -500; // Offset because of the gun's center of mass const int16_t zNeutralPoint = -2200; // Offset becasue of the gun's center of mass const uint16_t ultTimeThreshold = 10; // Trigger time in milliseconds const long ultDebounce = 700; // Debounce time, in milliseconds static uint16_t timeCounter = 0; long lastUlt = 0; if(ultActive){ lastUlt = lastUpdate; } if( ultActive != true && ay <= Gravity + yAngleTolerance && ay >= Gravity - yAngleTolerance && ax <= xNeutralPoint + xAngleTolerance && ax >= xNeutralPoint - xAngleTolerance && az <= zNeutralPoint + zAngleTolerance && az >= zNeutralPoint - zAngleTolerance ){ timeCounter++; if( timeCounter >= (ultTimeThreshold / IMU_UpdateRate) && lastUpdate >= lastUlt + ultDebounce ){ Keyboard.press('q'); Keyboard.release('q'); FastLED.setBrightness(255); ultActive = true; } } else{ timeCounter = 0; } }
This code works by measuring the acceleration of the MPU’s Y-axis (along the barrel of the gun) and comparing it to the acceleration from gravity. If the sensor reads close to 1 g a counter is incremented. If that counter exceeds a threshold indicating that the controller has been pointing down and remaining still for a significant length of time (my threshold is currently 10 milliseconds, or two samples), it activates the ultimate ability.
As the character’s ultimate ability doesn’t come up very often, a slight delay and a false-negative is preferred over a instantaneous activation and a false-positive. This is especially true for McCree whose ultimate has a build-up time, but other heroes with fast ults would be tweaked differently (e.g. Tracer).
To counteract false positives, the function also compares the values for the acceleration along both the X and Z axes. If the controller is perfectly vertical both of these should be zero, but because of the way the gun hangs due to its center of mass I added some offsets. All three axes also have some wiggle-room to counteract the noise of the sensor and the imprecision of human movements.
Debounce
Triggering the ultimate sets an ‘ultActive’ variable for debouncing. For as long as the ‘ultActive’ variable is true a variable is set to the last call time for the function (in milliseconds). After the ultimate is canceled, it cannot be called again for another 0.7 seconds. This delay is probably a little excessive, but there’s not much incentive to reduce it.
The ‘ulting’ variable is canceled by either the primary or secondary fire, the two options for McCree’s ult. This is to avoid accidentally firing with another ‘q’ press.
It likely doesn’t matter, but also note that this function is called before the flashbang, so you don’t waste your flashbang before your ult if the controller is moved too aggressively.
Angle Precision
While I was working on this I spent some time calculating angles for the various axes readings, but at the end of the day I decided it didn’t matter all that much. So long as the ult activates reliably and quickly at a “vertical” position the angle tolerances are set fine.
But for what it’s worth, you can calculate the angle of any axis relative to the vertical (gravity) using a bit of trigonometry. In a right triangle where the length of each side represents a vector for the acceleration in ‘g’, putting a constant ‘1 g’ on the hypotenuse for the pull of Earth’s gravity allows you to calculate the angle to the controller using arccos(x / 1).
Flashbang
Flashbang works similarly to the ultimate, although it correlates outputs from both the gyroscope and the accelerometer.
long lastFlash; void flashbang(int16_t gy, int16_t ac){ const int16_t flashThresholdG = 32677 >> FS_Sel; // Gyro threshold, adjusted for range const int16_t flashThresholdA = -26000; // Accelerometer threshold, 1.6 g const long flashDebounce = 100; // Debounce time, in milliseconds if(gy >= flashThresholdG && ac <= flashThresholdA){ if(lastUpdate >= lastFlash + flashDebounce){ Keyboard.press('e'); Keyboard.release('e'); lastFlash = lastUpdate; } } }
For aiming the program tracks the rotation of the gun along the MPU’s Z and X axis (mouse X and Y, respectively). The flashbang works with rotation of the remaining axis, Y. If the Y rotation is above a given threshold in the positive direction (i.e. the controller twisting clockwise about its barrel, from the perspective of the player) and the the X-axis accelerometer goes below a high negative threshold (showing a large force pushing the gun sideways), the flashbang activates.
Much like the ultimate, these variables will likely be read as high for longer than a single sample, so this ability includes a debounce – comparing the trigger time to the last time the ability was activated.
LED Updates
The LED is a lone addressable WS2812B, which helps simplify wiring. The library of choice for driving it is FastLED, which is both powerful and, well, fast.
#include <FastLED.h> const uint8_t LED_Brightness = 50; const CRGB runningColor = CRGB::Blue; const CRGB pauseColor = CRGB::Yellow; const CRGB actionColor = CRGB::Green; const CRGB ultColor = CRGB(0xFF3200); // Orange-ish red CRGB leds[1]; CRGB currentLED; void setup() { FastLED.addLeds<WS2812B, LedPin, GRB>(leds, 1); FastLED.setBrightness(LED_Brightness); } void handleIMU() { ... if(capRead() == true){ currentLED = pauseColor; } ... } void aiming(int16_t gyX, int16_t gyY){ ... currentLED = runningColor; ... } void updateLED(){ long ledHangTime = 150; // LED persist for flash and reload (ms) if(ultActive == true){ currentLED = ultColor; } else if( triggerActive || hammerActive || timestamp <= lastFlash + ledHangTime || timestamp <= lastReload + ledHangTime ){ currentLED = actionColor; } if(currentLED != leds[0]){ leds[0] = currentLED; FastLED.show(); } }
Colors are assigned at the top of the code and are fairly arbitrary. The LED’s default color is blue and it switches to yellow when the capacitive sensor is triggered. Any abilities (trigger, hammer, flashbang, reload) will switch the LED to green. Both flashbang and reload, which are otherwise instantaneous, will force the LED to ‘hang’ as the active color for 150 ms.
If the ultimate is active its color will override everything. The ultimate color is a reddish-orange to mimic the sun behind McCree. It also runs at max brightness, which is reset when the trigger is pulled.
The last thing the function does is check whether the calculated LED value is already showing on the LED. If it is, it doesn’t bother to update it which saves 30 µs and keeps interrupts active.
Failsafe
The last function is a failsafe. Whenever you’re programming stuff that controls keyboard / mouse movement it’s a good idea to add a failsafe so that if you mess up the programming in some way that makes the code spam inputs you can interrupt the code and reprogram the microcontroller.
const CRGB failsafeColor = CRGB::Purple; void setup(){ if(digitalRead(TriggerButtonPin) == 0){ failsafe(); // Just in-case something goes wrong } } void failsafe(){ const uint16_t FadeTime = 2000; // Total fade cycle time const uint8_t MaxBrightness = 127; const uint8_t MinBrightness = 10; leds[0] = failsafeColor; for(;;){ for(int i = MinBrightness; i < MaxBrightness; i++){ FastLED.setBrightness(i); FastLED.show(); delay(FadeTime / (MaxBrightness - MinBrightness)); } for(int i = MaxBrightness; i > MinBrightness; i--){ FastLED.setBrightness(i); FastLED.show(); delay(FadeTime / (MaxBrightness - MinBrightness)); } } }
If the trigger is held down while the controller is plugged in, the code escapes to the failsafe
function which has an infinite loop. The LED then fades in and out to let you know no inputs are being processed.
Conclusion
And that’s it! The controller is together, programmed, and ready to GO! Now all that’s left is to try it out and see how it plays!
When the project is all documented and done I hope to upload the finished code to a GitHub repository, but for now these snippets will have to suffice.
3 Comments
Jason Beal · March 21, 2022 at 3:31 pm
Dave,
I’ve been trying to break down and understand your extensive McCree Hammershot program. I don’t need any of the capacitive sensor or LED code, so I’ve been taking those out.
In struggling to understand it, I’ve decided to develop my own program using the Mouse library. I have gotten the MPU-6050 to take the place of my mouse cursor and I have it calibrated to the point where there is a very slight, negligible drift to the left. However, the mouse cursor is extremely sensitive to movement and I’m having trouble developing code that adjusts the sensitivity.
Along with this do you have any leads or tips on how to get the MPU 6050 to take the place of a second Thumb-stick using the XInput library?
Sorry for all of the questions. I’ve only had one semester working with Arduino programming so I’m trying to catch up to your awesome work!
Thanks!
Dave · March 21, 2022 at 7:17 pm
Hi Jason. It’s been a few years since I’ve looked at this, and I know it’s a little obtuse. I had meant to rewrite the aiming code and package it into a library but I never got around to it. If you’re using this in your own distributed project, bear in mind that everything is licensed under GPLv3.
Ignoring the Overwatch-specific sensitivity calculations, you should be able to multiply the `degreeChange` value by whatever you’d like to scale the mouse speed. The Overwatch-specific calculations are a little more in-depth than that, but it’s safe to ignore them for testing at least.
Adding gyro aiming to the thumbstick of the controller is tricky. It’s straightforward to remap the gyro output and send it to the joystick function, but nearly all games that use the right thumbstick for aiming add an acceleration curve which means the output won’t be linear. You’ll probably have to do some work to reverse engineer the acceleration curve in whatever game you’re using this for.
I hope that helps!
Jason Beal · March 23, 2022 at 3:04 pm
Since adding gyro aiming to the thumbstick has proven to be so difficult, I’ve decided to take a different approach and I decided to start a new code from scratch.
Rather than relying on the Xinput library, I’m going to focus my efforts on the Mouse and Keyboard library. Today I remapped the joystick to the WASD keyboard PC-gaming set up and I’ve got the MPU working with my mouse. Now I just need to “scale the mouse speed” like you mentioned previously. Once the MPU is scaled down I can start mapping all of my push buttons to inputs on the keyboard. I should be able to run simple FPS shooters like Call of Duty and Counter Strike in about a week or so.
I really appreciate all of the help you’ve been providing me!