[Перевод] Doom на дисковом телефоне Dialrhea
Dialrhea — это модифицированный дисковый телефон, переделанный для управления Doom«ом через Bluetooth. Мы собрали его за два дня во время хакатона «Internet Of Shit», организованного командой Technarium в Вильнюсе, Литва. Темой этого хакатона было создание устройств, которые совершенно бесполезны, но полностью функциональны.
Dialrhea в действии:
Мы даже сняли для него рекламный ролик:
Хотелось бы ещё провести мастер-класс, на котором мы собрали бы еще как минимум три устройства, а затем сыграли бы на них deathmatch в Doom.
Предыстория
Устройство было создано во время хакатона «Internet Of Shit». Это потрясающее мероприятие, которое было организовано командой Technarium в Вильнюсе, Литва, в 2017 и 2018 годах. В число номинаций вошли такие, как «Если загорелось, значит работает», «Наименее приватный онлайн-гаджет», «Это, скорее всего, незаконно», и команды упорно соревновались, чтобы превратить эти концепции в работающую технологию.
Наше устройство получило награду «Наименее дерьмовый проект», а также нам вручили приз за самый популярный проект. Призом, кстати, был насос для прочистки канализации, покрытый золотой краской, хе-хе.
Идея родилась во время пивных посиделок с Донатасом Валиулисом и Джюгасом Барткусомсом. Мы же втроём всё и реализовали. Я и Донатас позаботились о технических аспектах, а Джюгас отвечал за видео и рекламные материалы.
Джюгас также снял забавное психоделическое видео о создании устройства и обо всем хакатоне в целом.
Технические детали
Устройство построено на Arduino, а для беспроводной связи с компьютером использует Bluetooth LE. Оно представляет собой клавиатуру Bluetooth и может быть подключено к любому устройству, поддерживающему Bluetooth LE. Ниже вы можете увидеть разбивку компонентов, питающих Dialrhea.
Хотя итоговым вариантом использования устройства стала игра в Doom, устройство на самом деле полностью поддерживает несколько режимов работы:
Doom — в этом режиме устройство действует как игровой контроллер и настроено на управление классической игрой Doom (используя Doomsday Engine)
Эмодзи — этот режим лучше всего использовать на мобильных телефонах, он позволяет вводить эмодзи и отправлять их друзьям.
Скукота — в этом режиме Dialrhea просто выводит набранные номера (не рекомендуем к использованию)
Считывание данных с поворотного диска
Самым интересным в процессе было то, как на самом деле работает дисковый набор с технической точки зрения. Я из того поколения, которое застало дисковые телефоны, поэтому было действительно интересно понять, насколько прост механизм на самом деле, и почему меня било током, когда я касался телефонных проводов, играя в телефонного механика, только иногда, а не постоянно. Если вам интересно, вот видео, объясняющее механизм.
После того, как я понял, как работает этот механизм, реализовать его с помощью Arduino оказалось довольно просто.
Борьба с Bluetooth
Вероятно, самой большой проблемой было заставить Bluetooth работать должным образом. Мы решили использовать модуль Adafruit Bluefruit LE UART Friend, потому что он у меня был, и я уже пробовал работать с ним в другом проекте. Это очень эффективный модуль, но большинство проблем, с которыми мы столкнулись, были связаны со стабильностью и надежностью. Иногда все работало хорошо, иногда мы получали некоторые ошибки при запуске одного и того же кода. Мы прочитали много документации о протоколе рукопожатий, о том, как правильно выполнять сопряжение и т. д., но в итоге просто везде добавили циклы повторов и тайм-ауты, чтобы у чипа было время «прийти в себя» после каждой рискованной операции. Ниже вы можете увидеть полный исходный код Dialrhea.
#include
#include
#if not defined (_VARIANT_ARDUINO_DUE_X_) && not defined(ARDUINO_ARCH_SAMD)
#include
#endif
#include "Adafruit_BLE.h"
#include "Adafruit_BluefruitLE_UART.h"
#include "BluefruitConfig.h"
#define DEVICE_NAME "Dialrhea"
// Rotary dial input PIN
#define ROTARY_PIN 2
// Handset input PIN
#define HANDSET_PIN 3
// Operation mode potentiometer PIN
#define OPERATION_MODE_PIN A5
// How long to wait before sending keyup message for control keys in Gaming mode
#define CONTROL_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for fire button in Gaming mode
#define FIRE_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for keys that are supposed
// to be just one clicks
#define INSTANT_KEY_HOLD_DURATION 10
// Pins for status LED RGB legs
#define STATUS_LED_RED_PIN 4
#define STATUS_LED_GREEN_PIN 6
#define STATUS_LED_BLUE_PIN 5
// Constants for colors
#define COLOR_OFF 0
#define COLOR_RED 1
#define COLOR_GREEN 2
#define COLOR_BLUE 3
// Total number of keys that support timed presses
#define KEY_COUNT 14
// Index of handset button data in data arrays (Gaming mode, we need two because
//we are sending keys for both fire and open door)
#define KEY_GAMING_MODE_HANDSET_1_INDEX 10
#define KEY_GAMING_MODE_HANDSET_2_INDEX 11
// Index of handset button data in data arrays (Emoji mode)
#define KEY_EMOJI_MODE_HANDSET_INDEX 12
// Index of handset button data in data arrays (Boring mode)
#define KEY_BORING_MODE_HANDSET_INDEX 13
// Map of values for each type of dialed number and handset click
const int keyValues[KEY_COUNT] = {
0x42, // Number 0 in gaming mode (Currently quick load)
0x52, // Number 1 in gaming mode (Currently "up" arrow)
0x4F, // Number 2 in gaming mode (Currently "right" arrow)
0x50, // Number 3 in gaming mode (Currently "left" arrow)
0x51, // Number 4 in gaming mode (Currently "down" arrow)
0x2A, // Number 5 in gaming mode (Currently ?, next weapon)
0x00, // Number 6 in gaming mode
0x00, // Number 7 in gaming mode
0x00, // Number 8 in gaming mode
0x00, // Number 9 in gaming mode
0x10, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_1_INDEX) (Currently space)
0x2C, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_2_INDEX) (Currently 'm')
0x28, // Handset click in emoji mode (KEY_EMOJI_MODE_HANDSET_INDEX) (Currently Enter)
0x29 // Handset click in boring mode (KEY_BORING_MODE_HANDSET_INDEX) (Currently Esc)
};
// Durations for each type of mey (mapping the same as for keyValues array)
const int keyHoldDurations[KEY_COUNT] = {
INSTANT_KEY_HOLD_DURATION,
CONTROL_KEY_HOLD_DURATION,
CONTROL_KEY_HOLD_DURATION,
CONTROL_KEY_HOLD_DURATION,
CONTROL_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
FIRE_KEY_HOLD_DURATION,
FIRE_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION,
INSTANT_KEY_HOLD_DURATION
};
// Array for storing times each key was pressed
unsigned long keyPressTimes[KEY_COUNT];
// Array for storing states for each key
bool keyPressStates[KEY_COUNT];
// Variables required for handling input from rotary dial
int rotaryHasFinishedRotatingTimeout = 100;
int rotaryDebounceTimeout = 10;
int rotaryLastValue = LOW;
int rotaryTrueValue = LOW;
unsigned long rotaryLastValueChangeTime = 0;
bool rotaryNeedToEmitEvent = 0;
int rotaryPulseCount;
// Operation modes
#define OPERATION_MODE_GAMING 0 // Gaming controls fine tuned for the best game of all times: "Doom"
#define OPERATION_MODE_EMOJI 1 // Emojis + Enter
#define OPERATION_MODE_BORING 2 // Numbers + Esc
// Current operation mode
int operationMode;
// Emojis for each dialed number
const char* emojis[] = {":-O", ":poop:", ":-)", ":-(", ":-D", ":-\\", ";-)", ":-*", ":-P", ">:-("};
// Variables for handling handset clicker button
bool isHandsetPressed = false;
unsigned long handsetPressStartTime = 0;
unsigned long handsetPressStartTimeout = 60;
// Variable that determines weather the state of keys changed during processing of the loop (so we
// can send commands just once in the end of the loop if it is needed)
bool keyPressStateChanged;
// Config settings for Bluetooth LE module
#define FACTORYRESET_ENABLE 0
#define VERBOSE_MODE false // If set to 'true' enables debug output
#define MINIMUM_FIRMWARE_VERSION "0.6.6"
#define BLUEFRUIT_HWSERIAL_NAME Serial1
// Bluetooth LE module object
Adafruit_BluefruitLE_UART ble(BLUEFRUIT_HWSERIAL_NAME, BLUEFRUIT_UART_MODE_PIN);
void setup(void) {
pinMode(ROTARY_PIN, INPUT);
pinMode(HANDSET_PIN, INPUT_PULLUP);
pinMode(STATUS_LED_RED_PIN, OUTPUT);
pinMode(STATUS_LED_GREEN_PIN, OUTPUT);
pinMode(STATUS_LED_BLUE_PIN, OUTPUT);
setStatusLEDColor(COLOR_GREEN);
// Wait while serial connection is established (required for Flora & Micro or when you want to
// halt initialization till you open serial monitor)
// while (!Serial);
// Give some time for chip to warm up or whatever
delay(1000);
initializeSerialConnection();
initializeBLEModule();
// Delay a bit because good devices always take some time to start
delay(100);
setStatusLEDColor(COLOR_BLUE);
}
void loop(void) {
keyPressStateChanged = false;
refreshOperationMode();
handleHandset();
handleRotary();
processKeyUps();
// If state of pressed keys changed - send the new state
if (keyPressStateChanged)
sendCurrentlyPressedKeys();
}
// Sets the color of status LED
void setStatusLEDColor(int colorID) {
digitalWrite(STATUS_LED_RED_PIN, colorID == COLOR_RED ? HIGH : LOW);
digitalWrite(STATUS_LED_GREEN_PIN, colorID == COLOR_GREEN ? HIGH : LOW);
digitalWrite(STATUS_LED_BLUE_PIN, colorID == COLOR_BLUE ? HIGH : LOW);
}
// Outputs error message and bricks the revolutionary shitty machine
void error(const __FlashStringHelper*err) {
setStatusLEDColor(COLOR_RED);
Serial.println(err);
while (1);
}
// Blinks the status LED (only green supported for now)
void blink() {
setStatusLEDColor(COLOR_OFF);
delay(100);
setStatusLEDColor(COLOR_GREEN);
}
// Opens serial connection for debugging
void initializeSerialConnection() {
Serial.begin(9600);
Serial.println(F("Hello, I am the Dialrhea! Ready for some dialing action?"));
Serial.println(F("8-------------------------------------D"));
}
// Initializes Bluetooth LE module
void initializeBLEModule() {
// Buffer for holding commands that have to be sent to BLE module
char commandString[64];
setStatusLEDColor(COLOR_GREEN);
Serial.print(F("Initialising the Bluefruit LE module: "));
if (!ble.begin(VERBOSE_MODE)) error(F("Couldn't find Bluefruit, make sure it's in CoMmanD mode & check wiring?"));
Serial.println( F("Easy!") );
blink();
if (FACTORYRESET_ENABLE)
{
Serial.println(F("Performing a factory reset: "));
if (!ble.factoryReset()) error(F("Couldn't factory reset. Have no idea why..."));
Serial.println(F("Done, feeling like a virgin again!"));
}
blink();
// Disable command echo from Bluefruit
ble.echo(false);
blink();
Serial.println("Requesting Bluefruit info:");
ble.info();
blink();
// Change the device name so the whole world knows it as Dialrhea
Serial.print(F("Setting device name to '"));
Serial.print(DEVICE_NAME);
Serial.print(F("': "));
sprintf(commandString, "AT+GAPDEVNAME=%s", DEVICE_NAME);
if (!ble.sendCommandCheckOK(commandString)) error(F("Could not set device name for some reason. Sad."));
Serial.println(F("It's beautiful!"));
blink();
Serial.print(F("Enable HID Service (including Keyboard): "));
strcpy(commandString, ble.isVersionAtLeast(MINIMUM_FIRMWARE_VERSION) ? "AT+BleHIDEn=On" : "AT+BleKeyboardEn=On");
if (!ble.sendCommandCheckOK(commandString)) error(F("Could not enable Keyboard, we're in deep shit..."));
Serial.println(F("I'm now officially a keyboard!"));
blink();
// Make software reset (add or remove service requires a reset)
Serial.print(F("Performing a SW reset (service changes require a reset): "));
if (!ble.reset()) error(F("Couldn't reset?? Lame."));
Serial.println(F("Baby I'm ready to go!"));
Serial.println();
}
// Reads the position of operation mode select potentiometer and determines current operation mode
void refreshOperationMode() {
operationMode = floor((float)analogRead(OPERATION_MODE_PIN) / 342.0);
}
// Handles tracking the handset state
void handleHandset() {
// Ignore input until last action timeout passes (to filter out noise)
if (millis() - handsetPressStartTime > handsetPressStartTimeout) {
int ragelisCurrentValue = digitalRead(HANDSET_PIN);
if (!isHandsetPressed && ragelisCurrentValue == HIGH) {
isHandsetPressed = true;
handsetPressStartTime = millis();
onHandsetClicked();
}
else if (isHandsetPressed && ragelisCurrentValue == LOW) {
isHandsetPressed = false;
handsetPressStartTime = millis();
}
}
}
// Handles tracking of the rotary dial state
void handleRotary() {
int rotaryCurrentValue = digitalRead(ROTARY_PIN);
// If rotary isn't being dialed or it just finished being dialed
if ((millis() - rotaryLastValueChangeTime) > rotaryHasFinishedRotatingTimeout) {
// If rotary just finished being dialed - we need to emit the event
if (rotaryNeedToEmitEvent) {
// Emit the event (we mod the count by 10 because '0' will send 10 pulses)
onRotaryNumberDialed(rotaryPulseCount % 10);
rotaryNeedToEmitEvent = false;
rotaryPulseCount = 0;
}
}
// If rotary value has changed - register the time when it happened
if (rotaryCurrentValue != rotaryLastValue) {
rotaryLastValueChangeTime = millis();
}
// Start analyzing data only when signal stabilizes (debounce timeout passes)
if ((millis() - rotaryLastValueChangeTime) > rotaryDebounceTimeout) {
// This means that the switch has either just gone from closed to open or vice versa.
if (rotaryCurrentValue != rotaryTrueValue) {
// Register actual value change
rotaryTrueValue = rotaryCurrentValue;
// If it went to HIGH - increase pulse count
if (rotaryTrueValue == HIGH) {
rotaryPulseCount++;
rotaryNeedToEmitEvent = true;
}
}
}
// Store current value as last value
rotaryLastValue = rotaryCurrentValue;
}
// Event handler triggered when click of the handset button is registered
void onHandsetClicked() {
// Register state changes for handset button keys depending on the mode
if (operationMode == OPERATION_MODE_GAMING) {
if (keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] == false || keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] == false)
keyPressStateChanged = true;
keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] = true;
keyPressTimes[KEY_GAMING_MODE_HANDSET_1_INDEX] = millis();
keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] = true;
keyPressTimes[KEY_GAMING_MODE_HANDSET_2_INDEX] = millis();
} else if (operationMode == OPERATION_MODE_EMOJI) {
if (keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] == false)
keyPressStateChanged = true;
keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] = true;
keyPressTimes[KEY_EMOJI_MODE_HANDSET_INDEX] = millis();
} else if (operationMode == OPERATION_MODE_BORING) {
if (keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] == false)
keyPressStateChanged = true;
keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] = true;
keyPressTimes[KEY_BORING_MODE_HANDSET_INDEX] = millis();
}
}
// Event handler triggered when number was dialed on rotary dial
void onRotaryNumberDialed(int number) {
if (operationMode == OPERATION_MODE_GAMING) {
// Set key state for dialed key
if (keyPressStates[number] == false)
keyPressStateChanged = true;
keyPressStates[number] = true;
keyPressTimes[number] = millis();
} else if (operationMode == OPERATION_MODE_EMOJI) {
// Send emoji to happy device
sendCharArray(emojis[number]);
} else if (operationMode == OPERATION_MODE_BORING) {
// Form string from number and send it to device
char numberString[1];
sprintf(numberString, "%d", number);
sendCharArray(numberString);
}
}
// Sends raw command to BLE module and prints debug output
void sendBluetoothCommand(char *commandString) {
setStatusLEDColor(COLOR_OFF);
Serial.print(commandString);
ble.println(commandString);
if (ble.waitForOK()) {
Serial.println(F(" <- OK!"));
setStatusLEDColor(COLOR_BLUE);
}
else {
Serial.println(F(" <- FAILED!"));
setStatusLEDColor(COLOR_RED);
};
}
// Sends char array (string) to BLE module
void sendCharArray(char* charArray) {
char commandString[64];
sprintf(commandString, "AT+BleKeyboard=%s", charArray);
sendBluetoothCommand(commandString);
}
// Checks which keys are currently pressed and sends keycodes to BLE module
void sendCurrentlyPressedKeys() {
char commandString[64] = "AT+BleKeyboardCode=00-00";
for (int i=0; i keyHoldDurations[i]) {
keyPressStates[i] = false;
keyPressStateChanged = true;
}
}
}
}
На самом деле в этом устройстве есть очень интересный баг, который позволяет игроку очень быстро «прыгать» вперед. Я видел, как это происходило несколько раз, обычно после того, как кто-то некоторое время отчаянно колотил по устройству. Я понятия не имею, почему это происходит или как это воспроизвести, но это довольно прикольно, так я решил просто оставить это и назвать фичей.
Doom
Неслучайно в качестве объекта для управления с помощью Dialrhea был выбран классический Doom. Я безумно люблю эту игру и был одержим ею в детстве. Мой отец однажды принес ее с работы на 10 дискетах. Мне пришлось научиться писать пользовательские файлы autoexec.bat config.sys и загружать систему со специальной дискеты, содержащей минимальную версию MS-DOS и высокооптимизированный драйвер мыши, чтобы памяти было достаточно для запуска Doom на моей машине Intel 386 33 МГц, на которой было всего 4 Мб ОЗУ. «LH A:\MOUSE.COM» — волшебная строка, которая заставляла DOS загружать драйвер мыши в недоступную иным образом верхнюю память, тем самым выигрывая несколько дополнительных килобайт ОЗУ для Doom.
Еще одной причиной выбрать Doom было то, что что он может работать практически на чём угодно: на калькуляторах, микроволновках, беговых дорожках, вейпах и даже тестах на беременность! Если у устройства есть процессор и экран, найдется какой-нибудь гик, который попытается запустить Doom на этой машине. ПО сравнению с этим Doom на дисковом телефоне выглядит не так уж безумно :)
Спасибо за внимание!