...

пятница, 19 апреля 2019 г.

USB панель управления космическим кораблем своими руками


Здравствуйте, дорогие читатели!

Пришла мне тут одна идейка, а не собрать ли пульт управления космическим кораблем. На USB. С нативной поддержкой драйверов. Custom HID. Чтобы воткнул и всё работает, без всяких танцев и бубнов. В итоге, получился некий монструозный «геймпад» для космических симуляторов. В общем, судите сами.
Поначалу, я мало представлял, что будет в итоге. Хотелось два основных джойстика, как на Союзе-МС, немного переключателей, кнопок и несколько дисплеев.

Прикинув рабочую поверхность моего стола, выбрал размеры пульта по ширине и глубине 500*300 мм. А пошарив по строительным складам и магазинам в поисках стройматериалов, выбрал высоту 125мм. В итоге приобрел лист 4 мм фанеры, рейки 20*12 мм и доску 120*20 мм.

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

Но пока оставим малярные работы в стороне и я расскажу про электронную начинку.

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


Остальная мелочь проблем не вызвала. Контроллер выбрал STM32. В качестве АЦП для джойстиков 16-битные ADS1118. Также был куплен блок питания на 12 В. Собственно такое напряжение объясняется тем, что мне в руки попал указатель уровня топлива от «шахи», который я тоже хотел сюда же пристроить.


На фото блок питания, стабилизаторы на 5 и 3.3 В, STM32, MCP23017, ADS1118

Контроллер 100-выводный STM32F407VET6, к нему подключается:

2 селектора на 4 положения
1 переменный резистор
2 переключателя осей
4 основных оси
2 вспомогательных оси
2 регулирующих оси
4 клавишных переключателя по 2 кнопки каждый
20 кнопок со светодиодами
4 основных выключателя со светодиодами
2 кнопки-грибка со светодиодами
2 кнопки таймеров
3 выключателей со светодиодами
13 переключателей
2 ADS1118 (АЦП)
4 MAX7219 (8-знаковые LED-дисплеи)
2 TM1637 (дислей-часы)
1 PCF8574 (расширитель I/O, воткнут в знакосинтезирующий дисплей)


Получилась такая структурка

Чего-то многовато будет для сотни ног МК, решил я, и добавил сюда же расширители входов-выходов: четыре штуки MCP23017, на 16 входов или выходов каждый. Забегая вперед, скажу, что задержка опроса входов у расширителя получилась около 0.13 мс на одну микросхему, при скорости шины I2C 400кГц. То есть это с запасом перекрывает минимальное время опроса USB в 1 мс.

Чтобы не гонять шину I2C бесполезными запросами, у MCP23017 есть выхода-прерывания, которые устанавливаются при изменении состояния входов. Их я тоже применил в своем проекте. Как оказалось далее, из-за дребезга контактов эти прерывания оказались бесполезными.

АЦП ADS1118 несколько не успевает за скоростью USB, заявленная производительность у него составляет максимально 820 отсчетов в секунду, что равно 1.2 мс, при этом, он имеет несколько входов, которые внутри через мультиплексор уже подключены к АЦП. Я использовал 2 входа на одну микросхему, поэтому время обновления значений составляет 2.4 мс. Плоховато, но что поделаешь? К сожалению на али других 16-битных быстрых АЦП нет.


Внутри выглядит так, но после монтажа проводов гораздо хуже

Программа CPU написана в стиле программы ПЛК. Никаких блокирующих запросов. Ядро периферию не ждет, не успела и хрен с ним, на следующем цикле опросит. Никаких RTOS в проекте тоже нет, попробовал, уперся в минимальное время ожидания задачи 1 мс — получается медленно, если нам надо отправлять данные по USB с частотой 1 мс. В итоге понял, что буду использовать ОС без osDelay(), а тогда зачем RTOS? Просто, как в ПЛК, располагать инструкции программы один за другим внутри бесконечного цикла вполне достаточно.

Использовал, конечно же CubeMX и библиотеки HAL. Кстати, на HAL недавно перешел и удивился удобности. Не знаю, почему до сих пор не очень популярен, там главное разобраться поначалу, а потом пойдет очень просто. Такое чувство, что программируешь ардуину.

Девайс у нас будет USB custom HID. HID есть mouse, keyboard, gamepad, joystick, какие-то еще. А есть custom. Всё это не требует драйверов от операционной системы. Точнее, они уже написаны разработчиком. Кастомный девайс хорош тем, что мы сами комбинируем возможности всех вышеназванных устройств по своему усмотрению.

Вообще, USB штука очень сложная, имеет мануал почти в тысячу страниц и с наскока её не взять. Кто не хочет читать тяжелые мануалы, есть великолепная статья USB in a NutShell, погуглите. Также у неё есть перевод. Всё же попытаюсь некоторые моменты объяснить «на пальцах».

USB — пакетированная передача данных с кучей уровней и абстракций. Девайс у нас — никаких данных запрашивать не может, всю передачу инициализирует хост. Хост пишет и запрашивает данные в так называемые конечные точки, физически это некоторые буферы в памяти МК. Чтобы хост понимал по каким конечным точкам можно писать, а какие конечные точки читать и какие данные он может интерпретировать как кнопки и оси нашего устройства и, вообще, что это тут у нас за устройство, в начале коннекта он запрашивает дескрипторы девайса. Этих дескриптеров много и составлять их сложно и можно как угодно, и ошибиться тоже, где угодно. Физически они представляют собой массив байт.

На самом деле, CubeMX сгенерирует код инициализации Custom HID лучше нас.

Прошу обратить внимание на последней картинке под цифрой 3. Это размер дескриптора в байтах, который и определяет какие оси и кнопки есть на нашем девайсе. Генерируется этот дескриптор в программе HID Descriptor Tool. Там есть несколько примеров для самостоятельного изучения. Вообще, вот мой дескриптор. Там пока отсутствуют данные для дисплеев, для простоты понимания, но присутствуют все кнопки и оси джойстиков. Его нужно поместит в файл usbd_custom_hid_if.c. По-умолчанию, куб этот дескриптор делает пустым.

HID Descriptor (размер 104 байта)
__ALIGN_BEGIN static uint8_t CUSTOM_HID_ReportDesc_FS[USBD_CUSTOM_HID_REPORT_DESC_SIZE] __ALIGN_END =
{
  /* USER CODE BEGIN 0 */
        0x05, 0x01,                    // USAGE_PAGE (Generic Desktop)
        0x15, 0x00,                    // LOGICAL_MINIMUM (0)
        0x09, 0x04,                    // USAGE (Joystick)
        0xa1, 0x01,                    // COLLECTION (Application)
        0x05, 0x02,                    //   USAGE_PAGE (Simulation Controls)
        0x09, 0xbb,                    //   USAGE (Throttle)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x27, 0xff, 0xff, 0x00, 0x00,  //   LOGICAL_MAXIMUM (65535)
        0x75, 0x10,                    //   REPORT_SIZE (16)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x05, 0x01,                    //   USAGE_PAGE (Generic Desktop)
        0x09, 0x01,                    //   USAGE (Pointer)
        0xa1, 0x00,                    //   COLLECTION (Physical)
        0x09, 0x30,                    //     USAGE (X)
        0x09, 0x31,                    //     USAGE (Y)
        0x95, 0x02,                    //     REPORT_COUNT (2)
        0x81, 0x02,                    //     INPUT (Data,Var,Abs)
        0xc0,                          //   END_COLLECTION
        0x05, 0x01,                    //   USAGE_PAGE (Generic Desktop)
        0x09, 0x32,                    //   USAGE (Z)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x33,                    //   USAGE (Rx)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x34,                    //   USAGE (Ry)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x35,                    //   USAGE (Rz)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x36,                    //   USAGE (Slider)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x39,                    //   USAGE (Hat switch)
        0x15, 0x01,                    //   LOGICAL_MINIMUM (1)
        0x25, 0x08,                    //   LOGICAL_MAXIMUM (8)
        0x35, 0x00,                    //   PHYSICAL_MINIMUM (0)
        0x46, 0x0e, 0x01,              //   PHYSICAL_MAXIMUM (270)
        0x65, 0x14,                    //   UNIT (Eng Rot:Angular Pos)
        0x75, 0x08,                    //   REPORT_SIZE (8)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x05, 0x09,                    //   USAGE_PAGE (Button)
        0x19, 0x01,                    //   USAGE_MINIMUM (Button 1)
        0x29, 0x40,                    //   USAGE_MAXIMUM (Button 64)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
        0x75, 0x01,                    //   REPORT_SIZE (1)
        0x95, 0x40,                    //   REPORT_COUNT (64)
        0x55, 0x00,                    //   UNIT_EXPONENT (0)
        0x65, 0x00,                    //   UNIT (None)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
  /* USER CODE END 0 */
  0xC0    /*     END_COLLECTION              */
};


По сути его можно составлять как угодно, сначала задаются параметры USAGE PAGE и необходимый USAGE, например ось USAGE (Throttle), а затем после слова INPUT (Data,Var,Abs) система будет считать, что у нас есть ось «Газ». Размерность переменной оси и их кол-во задается параметрами LOGICAL_MAXIMUM, MINIMUM, REPORT_SIZE, REPORT_COUNT, которые должны стоять перед INPUT.

Более подробно про эти параметры, а также, что такое (Data,Var,Abs), можно прочесть в Device Class Definition for Human Interface Devices (HID) v1.11.

Ниже приведен пример инициализации оси Throttle из моего дескриптора. В данном примере Throttle имеет диапазон значений 0-65535, что соответствует одной переменной uint16_t.

  0x05, 0x02,                    //   USAGE_PAGE (Simulation Controls)
        0x09, 0xbb,                    //   USAGE (Throttle)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x27, 0xff, 0xff, 0x00, 0x00,  //   LOGICAL_MAXIMUM (65535)
        0x75, 0x10,                    //   REPORT_SIZE (16)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)

А да, еще, допустим, можно не писать LOGICAL_MAXIMUM, MINIMUM, REPORT_SIZE, REPORT_COUNT каждый раз, хост будет определять это значение по предыдущему параметру. Это иллюстрируют оси, которые идут один за другим, без указания размера и кол-ва:
     0x09, 0x32,                    //   USAGE (Z)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x33,                    //   USAGE (Rx)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x34,                    //   USAGE (Ry)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x35,                    //   USAGE (Rz)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x36,                    //   USAGE (Slider)

Всему этому дескриптору, который выше под спойлером, соответствует следующая структура. Она, по сути, уже не обязательна, просто так удобнее вести запись на основе указателей.
#pragma pack(push, 1)
typedef struct _myReportStruct
{
  uint16_t Throttle;
  uint16_t X;
  uint16_t Y;
  uint16_t Z;
  uint16_t Rx;
  uint16_t Ry;
  uint16_t Rz;
  uint16_t Slider;
  uint8_t Hat; // 0 - none, 1 - up, 2 - up-right, 3 - right, 4 - down-right...
  uint32_t Buttons1; // 32 buttons of 1 bit each
  uint32_t Buttons2; // 32 buttons of 1 bit each
}myReportStruct;
#pragma pack(pop)

volatile myReportStruct Desk;

Эту структуру можно посылать хосту функцией
USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, (uint8_t *) &Desk, sizeof(Desk));

Первый параметр — хендл USB, он у нас уже создан кубом. Возможно, понадобится подключить include-ом необходимый файл, где впервые инициализируется этот хендл и прописать extern USBD_HandleTypeDef hUsbDeviceFS;, чтобы с ним можно было работать. Второй параметр — указатель на нашу структуру и третий — размер структуры в байтах.

После заливки и прошивки контроллера можно заметить, что чего-то USB медленно шевелится. Данные с нашей панели обновляются не быстро. Чтобы было быстро, в файлах usbd_customhid.h нужно поменять #define CUSTOM_HID_EPIN_SIZE на максимальное значение 0x40, #define CUSTOM_HID_EPOUT_SIZE тоже поставить 0x40. В файле usbd_customhid.c найти комментарии в дескрипторе эндпойнтов "/* bInterval: Polling Interval (20 ms) */" и поменять байт дескриптора на 0x01 для каждого эндпойнта, всего два раза. Что будет соответствовать 1 мс обмена данными.


Должно получиться нечто подобное. Стандартное устройство без установки каких-либо драйверов

В общем-то, с функцией управления немного разобрались. Её сделать довольно легко и все кнопки и оси уже работают. Осталось сделать работу дисплеев. Делал я её делал, полгода примерно, и уже полгода панель пылится в долгом ящике. Нет времени. Поэтому решил выложить статью именно в таком виде, а то она рискует вообще не выйти.

С дисплеями всё тоже самое, что и с осями. Под них нужно дополнить наш дескриптор HID девайса, только указать что это дисплеи и вместо принятия данных Input, хост будет посылать данные Output.

Дескриптор HID девайса значительно разросся. Здесь я уже применил параметры Report ID, чтобы не забивать буфер приема/передачи и эндпойнты полными данными и различать, что за телеграмма нам пришла. Report ID представляет собой байт uint8_t со значением, который идет вначале телеграммы. Значением мы сами задаем в дескрипторе HID девайса.

CUSTOM_HID_ReportDesc_FS
//AXIS
        0x05, 0x01,                    // USAGE_PAGE (Generic Desktop)
        0x09, 0x04,                    // USAGE (Joystick)
        0xa1, 0x01,                    // COLLECTION (Application)28
        0x05, 0x02,                    //   USAGE_PAGE (Simulation Controls)
        0x09, 0xbb,                    //   USAGE (Throttle)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x27, 0xff, 0xff, 0x00, 0x00,  //   LOGICAL_MAXIMUM (65535)
        0x75, 0x10,                    //   REPORT_SIZE (16)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x85, 0x01,                                        //   REPORT_ID (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x05, 0x01,                    //   USAGE_PAGE (Generic Desktop)
        0x09, 0x01,                    //   USAGE (Pointer)
        0xa1, 0x00,                    //   COLLECTION (Physical)
        0x09, 0x30,                    //     USAGE (X)
        0x09, 0x31,                    //     USAGE (Y)
        0x95, 0x02,                    //     REPORT_COUNT (2)
        0x81, 0x02,                    //     INPUT (Data,Var,Abs)
        0xc0,                          //   END_COLLECTION
        0x05, 0x01,                    //   USAGE_PAGE (Generic Desktop)
        0x09, 0x32,                    //   USAGE (Z)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x33,                    //   USAGE (Rx)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x34,                    //   USAGE (Ry)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x35,                    //   USAGE (Rz)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)
        0x09, 0x36,                    //   USAGE (Slider)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)

        //HAT
        0x09, 0x39,                    //   USAGE (Hat switch)
        0x15, 0x01,                    //   LOGICAL_MINIMUM (1)
        0x25, 0x08,                    //   LOGICAL_MAXIMUM (8)
        0x35, 0x00,                    //   PHYSICAL_MINIMUM (0)
        0x46, 0x0e, 0x01,              //   PHYSICAL_MAXIMUM (270)
        0x65, 0x14,                    //   UNIT (Eng Rot:Angular Pos)
        0x75, 0x08,                    //   REPORT_SIZE (8)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)

        //Buttons
        0x05, 0x09,                    //   USAGE_PAGE (Button)
        0x19, 0x01,                    //   USAGE_MINIMUM (Button 1)
        0x29, 0x40,                    //   USAGE_MAXIMUM (Button 64)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
        0x75, 0x01,                    //   REPORT_SIZE (1)
        0x95, 0x40,                    //   REPORT_COUNT (64)
        0x55, 0x00,                    //   UNIT_EXPONENT (0)
        0x65, 0x00,                    //   UNIT (None)
        0x81, 0x02,                    //   INPUT (Data,Var,Abs)

        //LEDs
        0x85, 0x02,                                        // REPORT_ID (2)
        0x05, 0x08,                    // USAGE_PAGE (LEDs)
        0x09, 0x4B,                    // USAGE (Generic Indicator)
        0x95, 0x40,                    // REPORT_COUNT (16)
        0x91, 0x02,                    // OUTPUT (Data,Var,Abs)
        0xc0,                          // END_COLLECTION

        //LCD Displays
        0x05, 0x14,                    // USAGE_PAGE (Alphnumeric Display)
        0x09, 0x01,                    // USAGE (Alphanumeric Display)
        0x15, 0x00,                    // LOGICAL_MINIMUM (0)
        0xa1, 0x02,                    // COLLECTION (Logical)

        0x09, 0x32,                    //   USAGE (Cursor Position Report)
        0xa1, 0x02,                    //   COLLECTION (Logical)
        0x85, 0x04,                    //     REPORT_ID (4)
        0x75, 0x08,                    //     REPORT_SIZE (8)
        0x95, 0x01,                    //     REPORT_COUNT (1)
        0x25, 0x13,                    //     LOGICAL_MAXIMUM (19)
        0x09, 0x34,                    //     USAGE (Column)
        0xb1, 0x22,                    //     FEATURE (Data,Var,Abs,NPrf)
        0x25, 0x03,                    //     LOGICAL_MAXIMUM (3)
        0x09, 0x33,                    //     USAGE (Row)
        0x91, 0x22,                    //     OUTPUT (Data,Var,Abs,NPrf)
        0xc0,                          //   END_COLLECTION

        0x09, 0x2b,                    //   USAGE (Character Report)
        0xa1, 0x02,                    //   COLLECTION (Logical)
        0x85, 0x05,                    //     REPORT_ID (5)
        0x95, 0x14,                    //     REPORT_COUNT (20)
        0x26, 0xFF, 0x00,              //     LOGICAL_MAXIMUM (255)
        0x09, 0x2c,                    //     USAGE (Display Data)
        0x92, 0x02, 0x01,              //     OUTPUT (Data,Var,Abs,Buf)
        0xc0,                          //   END_COLLECTION


    0x09, 0x24,                    // USAGE (Display Control Report)
    0x85, 0x06,                    // REPORT_ID (6)
    0x95, 0x01,                    // REPORT_COUNT (1)
    0x91, 0x22,                    // OUTPUT (Data,Var,Abs,NPrf)
        0xc0,                          // END_COLLECTION

        //LED Displays
        0x05, 0x14,                    // USAGE_PAGE (Alphnumeric Display)
        0x09, 0x01,                    // USAGE (Alphanumeric Display)
        0x15, 0x00,                    // LOGICAL_MINIMUM (0)
        0xa1, 0x02,                    // COLLECTION (Logical)

        0x09, 0x2b,                    //   USAGE (Character Report)
        0xa1, 0x02,                    //   COLLECTION (Logical)
        0x85, 0x07,                    //     REPORT_ID (7)
        0x75, 0x08,                    //     REPORT_SIZE (8)
        0x95, 0x28,                    //     REPORT_COUNT (40)
        0x26, 0xFF, 0x00,              //     LOGICAL_MAXIMUM (255)
        0x09, 0x2c,                    //     USAGE (Display Data)
        0x92, 0x02, 0x01,              //     OUTPUT (Data,Var,Abs,Buf)
        0xc0,                          //   END_COLLECTION

        //Other DATA
    0x06, 0x00, 0xff,              // USAGE_PAGE (Generic Desktop)
    0x09, 0x01,                    // USAGE (Vendor Usage 1)
    0xa1, 0x01,                    // COLLECTION (Application)
    0x85, 0x08,                    //   REPORT_ID (8)
    0x09, 0x01,                    //   USAGE (Vendor Usage 1)
    0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
    0x27, 0xff, 0xff, 0x00, 0x00,  //   LOGICAL_MAXIMUM (65535)
    0x75, 0x10,                    //   REPORT_SIZE (16)
    0x95, 0x0A,                    //   REPORT_COUNT (10)
    0x91, 0x82,                    //   OUTPUT (Data,Var,Abs,Vol)

Обработка Output происходит в функции static int8_t CUSTOM_HID_OutEvent_FS(uint8_t event_idx, uint8_t state), которая по умолчанию находится в usbd_custom_hid_if.c.
static int8_t CUSTOM_HID_OutEvent_FS()
static int8_t CUSTOM_HID_OutEvent_FS(uint8_t event_idx, uint8_t state)
{
  /* USER CODE BEGIN 6 */
  uint8_t dataReceiveArray[USBD_CUSTOMHID_OUTREPORT_BUF_SIZE];
  USBD_CUSTOM_HID_HandleTypeDef     *hhid = (USBD_CUSTOM_HID_HandleTypeDef*)hUsbDeviceFS.pClassData;

  for (uint8_t i = 0; i < USBD_CUSTOMHID_OUTREPORT_BUF_SIZE; i++)
  {
        dataReceiveArray[i] = hhid->Report_buf[i];
  }

  if (dataReceiveArray[0] == 2) //report ID 2 leds
  {
       // если Report id == 2, то делаем что-то на основе данных в dataReceiveArray[1 + N], например, зажигаем LED 
  }

  if (dataReceiveArray[0] == 4) //report ID 4 cursor position
  {
       // если Report id == 4, то делаем что-то, например устанавливаем курсор на LCD 
  }

  if (dataReceiveArray[0] == 5) //report ID 5 display data
  {
     // если Report id == 5, то делаем что-то, например выводим данные с USB на LCD 
  }

  // и так далее, смотря сколько ID у нас в дескрипторе

  return (USBD_OK);
  /* USER CODE END 6 */
}


Осталось только написать программу на ПК, которая отправляет нужные репорты, чтобы рулить дисплеями. Впрочем, для проверки кода МК подойдет великолепная программа от ST: USB HID Demonstrator.


Тест LED дисплеев

На этом этапе я пока закончил. И не известно, начну ли снова.

Играется в симуляторы интереснее, чем с клавиатурой. Но не настолько, чтобы прямо был вау-эффект. Клавиатура, она тоже похожа на пульт управления. Но управлять осями-джойстиками, как минимум, необычно. Чувствуешь себя космонавтом. Правда, для полного погружения необходим скафандр.

Надеюсь, вам было интересно. Опечатки, неточности и бред присутствует. Желающие поковыряться в коде могут посмотреть здесь.
С уважением.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий