Источник вдохновения


На игровых выставках разработчики Objects in Space показывали демо своей игры с контроллером на кокпите огромного космического корабля. Он был дополнен загорающимися кнопками, аналоговыми приборами, световыми индикаторами состояния, переключателями и т.д… Это сильно влияет на погружение в игру:


На сайте игры выложен туториал по Arduino с описанием коммуникационного протокола для подобных контроллеров.

Я хочу создать то же самое для своей игры

В этом примере я потрачу примерно 40 долларов, чтобы добавить красивые, большие и тяжёлые переключатели на кокпит симулятора гонок. Основные затраты связаны с этими самыми переключателями — если бы я использовать простые переключатели/кнопки, то цена была в два раза ниже! Это настоящее оборудование, способное выдерживать 240 Вт мощности, а я буду пускать по ним только примерно 0,03 Вт.

Предупреждение: я решил сэкономить, поэтому оставляю ссылку на дешёвый китайский веб-сайт, где закупаю кучу разных компонентов/инструментов. Один из недостатков покупки компонентов по дешёвке заключается в том, что часто у них нет никакой документации, поэтому в статье я решу и эту проблему.

Основные компоненты






Рекомендуемые инструменты



Программное обеспечение


  • Arduino IDE для программирования процессора Arduino
  • Для создания контроллера, отображающегося как настоящий аппаратный USB-контроллер/джойстик:
    • FLIP для прошивки нового firmware в USB-контроллер Arduino
    • Библиотека arduino-usb на github
  • Для создания контроллера, с которым игра общается напрямую (или который отображается как виртуальный USB-контроллер/джойстик)
    • Моя библиотека ois_protocol на github
    • Драйвер vJoy, если вы хотите использовать контроллер как виртуальный USB-контроллер/джойстик.

Предупреждение


Я изучал электронику в старшей школе, научился пользоваться паяльником, узнал, что красные провода нужно соединять с красными, а чёрные с чёрными… Вольты, амперы, сопротивление и связывающие их уравнения — вот и всё, чем исчерпывалось моё формальное обучение электронике.

Для меня это был обучающий проект, поэтому в нём могут быть плохие советы или ошибки!

Часть 1. Собираем контроллер!


Работаем с переключателями без документации...


Как сказано выше, я покупаю дешёвые детали у розничного продавца с низкой маржей, поэтому первым делом нужно разобраться, как работают эти переключатели/кнопки.

Простая двухконтактная кнопка/переключатель


С кнопкой всё просто — в ней нет светодиодов и всего два контакта. Переключаем мультиметр в режим непрерывности/прозвонки () и касаемся щупами разных контактов — на экране будет отображаться OL (open loop, разомкнутая цепь): это означает, что между двумя щупами нет соединения. Затем нажимаем на кнопку, по-прежнему касаясь щупами контактов — на экране теперь должно отобразиться что-то типа 0.1? и мультиметр начнёт пищать (сообщая о том, что между щупами присутствует очень низкое сопротивление — замкнутая цепь).

Теперь мы знаем, что при нажатии кнопки цепь замыкается, а при отжатии — размыкается. На схеме это можно обозначить как простой выключатель:

Подключаем переключатель к Arduino


Найдите на плате Arduino два контакта: помеченный GND и помеченный «2» (или любым другим произвольным числом — это контакты ввода-вывода общего назначения, которыми мы можем управлять через ПО).

Если мы подключим переключатель таким образом, а потом прикажем Arduino сконфигурировать контакт «2» как контакт INPUT, то получим цепь, показанную слева (на рисунке ниже). При нажатии кнопки контакт 2 будет напрямую соединяться с землёй / 0V, а при отжатии контакт 2 не будет соединён ни с чем. Это состояние (ни с чем не соединён) называется «floating» (состояние с высоким импедансом) и, к сожалению, это не очень хорошее состояние для наших целей. Когда мы считываем данные с контакта в ПО (с помощью digitalRead(2)), получаем LOW, если контакт заземлён, и непредсказуемый результат (LOW или HIGH), если контакт находится в состоянии floating!

Чтобы исправить это, мы можем сконфигурировать контакт так, чтобы он находился в режиме INPUT_PULLUP, который соединяется с резистором внутри процессора и создаёт схему, показанную справа. В этой цепи при разомкнутом переключателе контакт 2 имеет путь к +5V, поэтому при его считывании результатом всегда будет HIGH. При замыкании переключателя у контакта по-прежнему будет путь с высоким сопротивлением к +5V, а также путь без сопротивления к земле / 0V, который «побеждает», благодаря чему при считывании контакта мы получаем LOW.


Разработчикам ПО порядок может показаться обратным — при нажатии кнопки мы считываем false / LOW, а при отжатии — true / HIGH.

Можно сделать и наоборот, но у процессора есть только встроенные подтягивающие резисторы и нет утягивающих вниз резисторов, поэтому мы будем придерживаться этой модели.

Простейшая программа для Arduino, которая считывает состояние переключателя и сообщает PC о его состоянии, выглядит примерно так, как показано ниже. Вы можете нажать кнопку загрузки в Arduino IDE, а затем открыть Serial Monitor (в меню Tools), чтобы увидеть результаты.

void setup() 
{
  Serial.begin(9600);
  pinMode(2, INPUT_PULLUP);
}

void loop() 
{
  int state = digitalRead(pin);
  Serial.println( state == HIGH ? "Released" : "Pressed" );
  delay(100);//artifically reduce the loop rate so the output is at a human readable rate...
}

Другие переключатели почти без документации...


Светодиодный переключатель с тремя контактами


К счастью, на основных переключателях моей панели есть пометки трёх контактов:


Я не полностью уверен, как он работает, поэтому мы снова переключим мультиметр в режим непрерывности и коснёмся всех пар контактов при включенном и отключенном переключателе… однако на этот раз мультиметр вообще не пищит, когда мы касаемся щупами [GND] и [+] при «включенном» переключателе! Единственная конфигурация, при которой мультиметр пищит (обнаруживает соединение) — когда переключатель «включен», а щупы находятся на [+] и [lamp].

Светодиод внутри переключателя блокирует измерения непрерывности, поэтому из проведённых выше проверок мы можем предположить, что LED подключен непосредственно к контакту [GND], а не к контактам [+] и [lamp]. Далее мы переключим мультиметр в режим проверки диодов (символ ) и снова проверим пары контактов, но на этот раз важна полярность (красный и чёрный щуп). Теперь если мы соединим красный щуп с [lamp], а чёрный — с [GND], то светодиод загорится, а на мультиметре отобразится 2.25V. Это прямое напряжение диода, или минимальное напряжение, необходимое для его включения. Вне зависимости от положения переключателя, 2.25V от [lamp] к [GND] заставляет LED загореться. Если мы соединим красный щуп с [+], а чёрный — с [GND], то светодиод загорится только при включённом переключателе.

Из этих показаний мы можем предположить, что внутренности этого переключателя выглядят примерно как на схеме ниже:

  1. [+] и [lamp] замыкаются накоротко, когда переключатель включен/замкнут.
  2. Положительное напряжение от [lamp] к [GND] всегда зажигает светодиод.
  3. Положительное напряжение от [+] к [GND] зажигает светодиод только при включенном/замкнутом переключателе.



Честно говоря, о присутствии резистора здесь можно только догадываться. Светодиод должен быть соединён с соответствующим резистором, чтобы ограничивать подаваемый на него ток, или он сгорит. Мой не сгорел и похоже, что работает правильно. На форуме веб-сайта продавца я нашёл пост, в котором говорится об установленном резисторе, поддерживающем работу до 12 В, и это сэкономило мне время на проверку/вычисления подходящего резистора.

Подключаем переключатель к Arduino


Проще всего использовать переключатель с Arduino, проигнорировав контакт [lamp]: подключить [GND] к GND в Arduino и соединить [+] с одним из пронумерованных контактов Arduino, например 3.

Если мы сконфигурируем контакт 3 как INPUT_PULLUP (так же, как и для предыдущей кнопки), то придём к показанному ниже результату. Слева вверху показано значение, которое мы будем получать, выполнив «digitalRead(3)» в коде Arduino.

Когда переключатель включен/замкнут, мы считываем LOW и светодиод загорается! Для использования такого переключателя в данной конфигурации мы можем использовать тот же код Arduino, что и в примере с кнопкой.


Проблемы этого решения


После подключения к Arduino полная цепь выглядит так:


Однако здесь мы можем увидеть, что при замыкании переключателя кроме небольшого ограничивающего ток резистора перед LED (я предполагаю, что его сопротивление 100 Ом) есть и ещё и подтягивающий резистор на 20 кОм, который ещё больше снижает величину тока, текущего через светодиод. Это означает, что хотя цепь и работает, светодиод будет не очень ярким.

Ещё один недостаток этой схемы в том, что у нас нет программного контроля над LED — он включён, когда включён переключатель, и отключен в противоположном случае.

Можно посмотреть, что случится, если мы подключим контакт [lamp] или к 0V, или к +5V.

Если [lamp] подключен к 0V, то светодиод постоянно отключен (вне зависимости от позиции переключателя), а распознавание позиции Arduino всё равно выполняется. Это позволяет нам при желании программно отключать LED!


Если [lamp] подключен к +5V, то светодиод постоянно включен (вне зависимости от позиции переключателя), однако распознавание позиции Arduino поломано — с контакта всегда будет считываться HIGH.


Подключаем этот переключатель к Arduino правильно


Мы можем преодолеть описанные выше ограничения (низкий ток/яркость светодиода и отсутствие программного контроля над светодиодом), написав больше кода! Чтобы разрешить конфликт между возможностью управления светодиодом и сломанным из-за него распознаванием позиции, мы можем разделить две задачи по времени, то есть временно отключать LED при считывании контакта датчика (3).

Сначала подключим контакт [lamp] к ещё одному контакту Arduino общего назначения, например, к 4, чтобы можно было управлять lamp.

Чтобы создать программу, которая будет правильно считывать позицию переключателя и управлять светодиодом (мы заставим его мигать), нам достаточно просто отключать светодиод перед считыванием состояния переключателя. Светодиод будет отключаться всего на доли миллисекунд, поэтому мерцание не должно быть заметно:

int pinSwitch = 3;
int pinLed = 4;
void setup() 
{
  //connect to the PC
  Serial.begin(9600);
  //connect our switch's [+] connector to a digital sensor, and to +5V through a large resistor
  pinMode(pinSwitch, INPUT_PULLUP);
  //connect our switch's [lamp] connector to 0V or +5V directly
  pinMode(pinLed, OUTPUT);
}

void loop() 
{
  int lampOn = (millis()>>8)&1;//make a variable that alternates between 0 and 1 over time
  
  digitalWrite(pinLed, LOW);//connect our [lamp] to +0V so the read is clean
  int state = digitalRead(pinSwitch);
  if( lampOn )
    digitalWrite(pinLed, HIGH);//connect our [lamp] to +5V
  
  Serial.println(state);//report the switch state to the PC
}

В Arduino Mega контакты 2-13 и 44-46 могут использовать функцию analogWrite, которая на самом деле не создаёт напряжения от 0V до +5V, а аппроксимирует его при помощи прямоугольной волны. При желании можно использовать её для управления яркостью светодиода! Этот код заставит свет пульсировать, а не просто мерцать:

void loop() 
{
  int lampState = (millis()>>1)&0xFF;//make a variable that alternates between 0 and 255 over time
  
  digitalWrite(pinLed, LOW);//connect our [lamp] to +0V so the read is clean
  int state = digitalRead(pinSwitch);
  if( lampState > 0 )
    analogWrite(pinLed, lampState);
}

Подсказки по сборке


Пост и так уже довольно большой, так что я не буду добавлять ещё и туториал по пайке, можете его загуглить!

Однако приведу самые базовые советы:

  • При соединении проводов с большими металлическим контактами сначала убедитесь, что паяльник нагрелся и какое-то время нагревайте и металлический контакт. Смысл пайки заключается в образовании постоянного соединения созданием сплава, но если горячей является только одна часть соединения, то у вас запросто может получиться «холодное соединение», которое выглядит как соединение, но на самом деле не соединено.
  • При соединении двух проводов наденьте сначала на один из них кусок термоусадочной трубки — после соединения трубку надеть будет нельзя. Это кажется очевидным, но я постоянно это забываю и мне приходится использовать вместо трубки изоленту… Протяните термоусадочную трубку подальше от соединения, чтобы она не нагрелась раньше времени. Проверив паянное соединение сдвиньте на него трубку и нагрейте её.
  • Тонкие маленькие соединительные провода, которые я упоминал в начале, хорошо подходят для соединений без пайки (например, при подключении к Arduino!), но довольно хрупкие. После пайки используйте для их закрепления клеевой пистолет и устраните из самого соединения все напряжения. Например, красные провода на показанном ниже снимке при работе можно случайно потянуть, поэтому после пайки я зафиксировал их каплей горячего клея:


Часть 2. Превращаем устройство в игровой контроллер!


Чтобы ОС распознала устройство как игровой USB-контроллер, нужен достаточно простой код, но, к сожалению, также необходимо заменить firmware USB-чипа Arduino другим, которое можно взять здесь: https://github.com/harlequin-tech/arduino-usb.

Но после заливки этого firmware в Arduino устройство становится USB-джойстиком и перестаёт быть Arduino. Поэтому чтобы перепрограммировать его, нужно заново перепрошить исходную firmware Arduino. Эти итерации довольно мучительны — загружаем код Arduino, прошиваем firmware джойстика, тестируем, прошиваем firmware arduino, повторяем…

Пример программы для Arduino, которую можно использовать с этим firmware, показан ниже — он конфигурирует три кнопки в качестве вводов, считывает их значения, копирует значения в структуру данных, ожидаемую этим firmware, а затем отправляет данные. Смыть, намылить, повторить.

// define DEBUG if you want to inspect the output in the Serial Monitor
// don't define DEBUG if you're ready to use the custom firmware
#define DEBUG 

//Say we've got three buttons, connected to GND and pins 2/3/4
int pinButton1 = 2;
int pinButton2 = 3;
int pinButton3 = 4;
  
void setup() 
{
  //configure our button's pins properly
  pinMode(pinButton1, INPUT_PULLUP);
  pinMode(pinButton2, INPUT_PULLUP);
  pinMode(pinButton3, INPUT_PULLUP);
  
#if defined DEBUG
  Serial.begin(9600);
#else
  Serial.begin(115200);//The data rate expected by the custom USB firmware
  delay(200);
#endif
}

//The structure expected by the custom USB firmware
#define NUM_BUTTONS  40
#define NUM_AXES  8        // 8 axes, X, Y, Z, etc
typedef struct joyReport_t {
    int16_t axis[NUM_AXES];
    uint8_t button[(NUM_BUTTONS+7)/8]; // 8 buttons per byte
} joyReport_t;

void sendJoyReport(struct joyReport_t *report)
{
#ifndef DEBUG
  Serial.write((uint8_t *)report, sizeof(joyReport_t));//send our data to the custom USB firmware
#else
  // dump human readable output for debugging
  for (uint8_t ind=0; ind<NUM_AXES; ind++)
  {
    Serial.print("axis[");
    Serial.print(ind);
    Serial.print("]= ");
    Serial.print(report->axis[ind]);
    Serial.print(" ");
  }
  Serial.println();
  for (uint8_t ind=0; ind<NUM_BUTTONS/8; ind++)
  {
    Serial.print("button[");
    Serial.print(ind);
    Serial.print("]= ");
    Serial.print(report->button[ind], HEX);
    Serial.print(" ");
  }
  Serial.println();
#endif
}

joyReport_t joyReport = {};
void loop() 
{
  //check if our buttons are pressed:
  bool button1 = LOW == digitalRead( pinButton1 );
  bool button2 = LOW == digitalRead( pinButton2 );
  bool button3 = LOW == digitalRead( pinButton3 );
  
  //write the data into the structure
  joyReport.button[0] = (button1?0x01:0) | (button2?0x02:0) | (button3?0x03:0);
  //send it to the firmware
  sendJoyReport(joyReport)
}

Часть 3. Интегрируем устройство с собственной игрой!


Если у вас есть контроль над игрой, с которой должно взаимодействовать устройство, то в качестве альтернативы можно общаться с контроллером напрямую — нет необходимости делать его видимым для ОС как джойстик! В начале поста я упомянул Objects In Space; именно такой подход использовали её разработчики. Они создали простой коммуникационный ASCII-протокол, позволяющий контроллеру и игре общаться друг с другом. Достаточно просто перечислить последовательные порты системы (они же COM-порты в Windows; кстати, посмотрите, как ужасно это выглядит на C), найти порт, к которому подключено устройство с названием «Arduino», и начать считывать/записывать ASCII по этой ссылке.

На стороне Arduino мы просто используем функции Serial.print, которые применялись в показанных выше примерах.

В начале этого поста я также упоминал мою библиотеку для решения этой задачи: https://github.com/hodgman/ois_protocol.

Она содержит код на C++, который можно интегрировать в игру и использовать её в качестве «сервера», и код Arduino, который можно выполнять в контроллере, чтобы использовать его в качестве «клиента».

Настраиваем Arduino


В example_hardware.h я создал классы, чтобы абстрагировать отдельные кнопки/переключатели; например, «Switch» — это простая кнопка из первого примера., а «LedSwitch2Pin» — переключатель с управляемым светодиодом из второго примера.

Код примера для моей панели кнопок находится в example.ino.

В качестве небольшого примера давайте допустим, что у нас есть единственная кнопка, которую нужно отправлять в игру, и один управляемый игрой светодиод. Необходимый код Arduino выглядит так:

#include "ois_protocol.h"
//instantiate the library
OisState ois;

//inputs are values that the game will send to the controller
struct 
{
  OisNumericInput myLedInput{"Lamp", Number};
} inputs;

//outputs are values the controller will send to the game
struct 
{
  OisNumericOutput myButtonOutput{"Button", Boolean};
} outputs;

//commands are named events that the controller will send to the game
struct 
{
  OisCommand quitCommand{"Quit"};
} commands;

int pinButton = 2;
int pinLed = 3;

void setup() 
{
  ois_setup_structs(ois, "My Controller", 1337, 42, commands, inputs, outputs);

  pinMode(pinButton, INPUT_PULLUP);
  pinMode(pinLed, OUTPUT);
}

void loop()
{
  //read our button, send it to the game:
  bool buttonPressed = LOW == digitalRead(pin);
  ois_set(ois, outputs.myButtonOutput, buttonPressed);
  
  //read the LED value from the game, write it to the LED pin:
  analogWrite(pinLed, inputs.myLedInput.value);
  
  //example command / event:
  if( millis() > 60 * 1000 )//if 60 seconds has passed, tell the game to quit
    ois_execute(ois, commands.quitCommand);
  
  //run the library code (communicates with the game)
  ois_loop(ois);
}

Настраиваем игру


Код игры написан в стиле «single header». Для импорта библиотеки включим в игру oisdevice.h.

В едином файле CPP, прежде чем выполнять #include заголовка, напишем #define OIS_DEVICE_IMPL и #define OIS_SERIALPORT_IMPL — это добавит в файл CPP исходный код классов. Если у вас есть собственные утверждения, логгинг, строки или векторы, то существует несколько других макросов OIS_*, которые можно определить перед импортом заголовка, чтобы воспользоваться возможностями движка.

Для перечисления COM-портов и создания соединения с конкретным устройством можно использовать такой код:

OIS_PORT_LIST portList;
OIS_STRING_BUILDER sb;
SerialPort::EnumerateSerialPorts(portList, sb, -1);
for( auto it = portList.begin(); it != portList.end(); ++it )
{
	std::string label = it->name + '(' + it->path + ')';
	if( /*device selection choice*/ )
	{
		int gameVersion = 1;
		OisDevice* device = new OisDevice(it->id, it->path, it->name, gameVersion, "Game Title");
      ...
	}
}

Получив экземпляр OisDevice, нужно регулярно вызывать его функцию-член Poll (например, в каждом кадре), можно получать текущее состояние вывода контроллера с помощью DeviceOutputs(), использовать события устройства с помощью PopEvents() и отправлять устройству значения с помощью SetInput().

Пример приложения, делающего всё это, можно найти здесь: example_ois2vjoy/main.cpp.

Часть 4. Что если я хочу части 2 и 3 одновременно?


Чтобы контроллер мог работать в других играх (часть 2), нужно установить собственное firmware и одну программу Arduino, но чтобы контроллер полностью программировался игрой, мы использовали стандартное firmware Arduino и другую программу Arduino. Но что если мы хотим иметь обе возможности одновременно?

Пример приложения, на который я давал ссылку выше (ois2vjoy), решает эту проблему.

Это приложение общается с OIS-устройством (программа из части 3), а затем на PC преобразует эти данные в обычные данные контроллера/джойстика, которые потом передаются в виртуальное устройство контроллера/джойстика. Это означает, что можно позволить своему контроллеру постоянно использовать библиотеку OIS (другое firmware не требуется), а если мы захотим использовать его как обычный контроллер/джойстик, то просто запустим на PC приложение ois2vjoy, выполняющее преобразование.

Часть 5. Завершение


Надеюсь, кому-то эта статья показалась полезной или интересной. Спасибо, что дочитали до конца!

Если вам стало любопытно, то я приглашаю вас поучаствовать в развитии библиотеки ois_protocol! Думаю, будет здорово разработать единый протокол для поддержки всевозможных самодельных контроллеров в играх и стимулировать игры к прямой поддержке самодельных контроллеров!

Комментарии (14)


  1. Andrey_Dolg
    07.03.2019 12:26

    Спасибо! Моя ардуинка пыльненькая посмотрела на меня с укором. =)


  1. Bookvarenko
    07.03.2019 13:42

    Полезная статья для тех, кто хотел бы спаять приблуду для квест-рума, но не знает, с чего начать.


  1. Slavik_Kenny
    07.03.2019 14:50

    Если уж хочется сэкономить, то зачем брать мегу? Для подобного и Uno вполне достаточно будет.


  1. Rudmz
    07.03.2019 15:01

    Не хило так рекомендуется «сэкономить» на рекомендованных инструментах.


    1. Alexeyslav
      07.03.2019 15:05

      Это и правда экономия. Даже для редких случаев лучше работать нормальным паяльником, чем кое-каким за 19$… Разница в 30$ сэкономит целый клубок нервов, которые стоят дороже.


      1. Alex_ME
        08.03.2019 00:07

        ИМХО, это не такой уж и плохой паяльник. Я ожидал простейшего нерегулируемого паяльника. Ну или самого простого регулируемого с али (у меня был подобный, и по сравнению с маломощным нерегулируемым медным, просто шикарно). А TS100, имхо, странная штука.


  1. iAmGeorge
    07.03.2019 16:21

    Если мы экономим , а не рекламируем banggood Arduino Mega2560
    Arduino Mega2560 ~$6
    гоночная панель переключателей зажигания ~$22


    1. radonit
      07.03.2019 18:33

      А паяльник?


      1. iAmGeorge
        07.03.2019 23:40

        Паяльник в туже цену, можно смело вбивать его в поиск на али, если важно купить все в одном месте, их есть там


    1. VBKesha
      07.03.2019 20:55

      Нафига нам ардуина если мы экономим ru.aliexpress.com/item/STM32F103C8T6-ARM-STM32-Minimum-System-Development-Board-Module/32656048071.html
      В подарок ещё получим нормальный USB.


    1. osmanpasha
      08.03.2019 20:48

      Ардуина по вашей ссылке не совсем подойдёт, т.к для USB вместо atmega16u имеет ch340g (usb-uart конвертер), поэтому никак не сможет быть для системы джойстиком. Второй вариант с прослойкой ois, впрочем, будет работать


  1. deepform
    08.03.2019 00:24

    Скажите, а реально ли сделать двухканальный usb микшер?


    1. gbg
      08.03.2019 14:59

      Давайте уточним:
      — сделать аналог контроллера, который будет пересылать команды в виде MIDI-событий в комп
      — сделать внешний аудиоинтерфейс?

      Первое — да, можно, причем довольно просто. Второе — не на ардуине и потребует специальных знаний в области проектирования аналоговой электроники.


  1. CHolfield
    08.03.2019 11:45

    Для подачи питания на девайс рекомендую вакуумные размыкатели на 220кВ