Подарок сыну и списанный прибор
Не так давно мой сын, которому тогда было 5 лет, попросил игровую консоль. Я немного подумал, и решил, что до полноценной консоли ему бы ещё подрасти… и решил сделать её сам. Проведя ревизию полезного «железа» я нашёл 2.4“” TFT SPI дисплей, шилд джойстика и ESP8266. Сначала был написан в Arduino IDE код, затем найден корпус… но я начну от обратного. Поиск подходящего корпуса начался с Авито. Куплена нерабочая китайская консоль, но сразу после получения успешно отремонтирована подключением шлейфа дисплея и поиск продолжился… На работе был присмотрен списанный нормирующий преобразователь интерфейсов, замерен, и… признан пригодным к адаптации в качестве корпуса.

Старый аккумулятор, термоклей и болгарка
Дома в кустарных условиях и подручными средствами произведена финальная примерка, разметка и работы по монтажу. Шилд джойстика немного не помещался, поэтому плату было решено доработать болгаркой под размеры корпуса. Гребёнка пинов с него частично и с ESP8266 была полностью демонтирована, а заготовленные провода от перемычек для Ардуино припаяны напрямую для экономии места, уложены настолько компактно, насколько это было возможно.
Когда дело дошло до питания, было принято решение найти рабочую компактную батарею от мобильного телефона. И она нашлась, от старого телефона Samsung. Пришлось «толкнуть» её немного лабораторником, но зарядку она начала брать. У товарища был выпрошен модуль зарядки TP4056, в закромах найден ползунковый переключатель… и сборка началась. Для крепежа был использован надёжно себя зарекомендовавший тысячами самодельщиков термоклей. С использованием просьб, угроз и
непростительных заклинаний устройство наконец было собрано.



Битва за пины и скорость дисплея
Теперь стоит написать об ограничениях ESP8266 в корпусе Node MCU… и как я их обходил)))) Во‑первых, физические — мало входов, а аналоговый вообще один — (А0). Джойстик был подключен только одним выходом, по оси X, позволяя игроку перемещаться горизонтально. Диапазон значений 0 — 1023, поэтому я сделал небольшое программное окно от дребезга контактов. Кнопок я подключил две. Выходы заняли зуммер с дисплеем, про который тоже нужно сказать пару слов.
#define PIN_JOY_X A0 // Аналоговый пин. Читает напряжение от 0 до 3.3В (0 - 1023) #define PIN_BTN_A D3 // Цифровой пин. Кнопка "Огонь" или "Идти вперед" #define PIN_BTN_B D0 // Кнопка "взять кубик" или "пауза" #define BUZZER_PIN D6 // Пин для зуммера void loop() { if (gameState == 0) { // МЕНЮ int xVal = analogRead(PIN_JOY_X); // 200 единиц мёртвая зона, о которой написал выше в статье if (xVal < 400) menuSelection = 1; // Наклон влево if (xVal > 600) menuSelection = 2; // Наклон вправо // Рисуем пункты меню. Если выбран этот пункт — красим в Зеленый, иначе в Белый. tft.setTextColor(menuSelection == 1 ? TFT_GREEN : TFT_WHITE, TFT_BLACK); tft.drawCentreString("1. MINECRAFT 3D", 160, 100, 4); if (digitalRead(PIN_BTN_A) == LOW) // Нажали кнопку "ОК" { gameState = menuSelection; // Меняем состояние — игра начнется в следующем цикле tft.fillScreen(TFT_BLACK); delay(300); // Задержка, чтобы одно нажатие не считалось дважды }} // Если gameState не 0, запускаем соответствующий игровой движок else if (gameState == 1) loopRaycast(); else if (gameState == 2) loopInvaders(); }
У дисплея есть тачскрин, который, в принципе, можно было бы использовать. Но сын ещё мал для такого управления (как мне тогда казалось) и я подключил простые и удобные кнопки. Поначалу была использована библиотека Adafruit GFX, но с ней дисплей обновлялся с около нулевой скоростью. Вопрос был решён подключением библиотеки TFT_eSPI, но требовалась настройка файла User_Setup.h внутри папки самой библиотеки (выбор драйвера дисплея и пинов).
Space Invaders и оптимизация Джона Кармака
Железо подключено (но я начал повествование в обратном порядке, поэтому подключено оно было на макетной плате ещё перед сборкой в корпус),и встал вопрос о том, какую игру туда загрузить / портировать / написать. Выбор пал на космических захватчиков, классическую 2D аркаду. Но начав её писать я понял, что просто стрелять по квадратикам скучно и решил добавить уровни. А чтобы не было просто, как в тире, с каждым уровнем скорость противников увеличивается. Также для каждого третьего уровня я сделал Босса, который перемещаясь шевелит тремя разноцветными щупальцами и погибает с эффектом взрыва, взятым мной из старых игр — концентрические круги и сигналы пищалки на разных частотах.
В аркаде добавлен счёт заработанных очков, лучший рекорд сохраняется в EEPROM. Также добавлен спавн бонусов и одного антибонуса от уничтоженных противников. Для движения объектов использовал приём Джона Кармака — закрашивал объект в старых координатах и отрисовывал в новых. Повторил логику движения пришельцев зигзагом… И вот несколько уровней пройдено, некоторые баги исправлены…
Меня посетила мысль, что сделать такую вещь ради простой аркады моветон… и решил попробовать запустить raycast на этой приставке. Нет, ну портируют же люди DOOM на калькуляторы, чем ESP8266 хуже? Вооружившись статьёй LodeV я начал работу.


Raycast, оптимизация старой школы, пауза и конечный автомат
Для начала логика переключения — конечный автомат на 3 состояния: gameState == 0 // Выбор игры, gameState == 1 // loopRaycast, gameState == 2 // loopInvaders Выбор состояния 1 и 2 джойстиком, за компанию пауза, которой пока не было и выход из игры в состояние 0 нажатием и удержанием двух кнопок с задержкой.
void loop() { if (gameState == 0) { // Логика меню... } else if (gameState == 1) loopRaycast(); else if (gameState == 2) loopInvaders(); // Секретный выход: А + B на 1 секунду if (digitalRead(PIN_BTN_A) == LOW && digitalRead(PIN_BTN_B) == LOW) { delay(1000); if (digitalRead(PIN_BTN_A) == LOW && digitalRead(PIN_BTN_B) == LOW) { gameState = 0; tft.fillScreen(TFT_BLACK); } } } // Обычно delay это плохо, но здесь это защита от случайного нажатия
Был написан классический raycast движок, который работает стабильно. Оптимизирована отрисовка стен, где столбцы пикселей выводятся через один, эффект фонаря ( линейного затухания света ), где распаковывается 16 бит формат RGB на три байта и совершается побитовый сдвиг и собирается обратно, потому что дисплей ждёт 16 бит...
uint16_t applyLantern(uint16_t color, float dist) { float range = 10.0; // На сколько блоков светит наш фонарик, подобрал опытным путём if(dist > range) return TFT_BLACK; // Если стена дальше 10 блоков — там полная тьма // 1. Распаковка цвета (RGB565) // Экран понимает цвет в 16 битах. Нам нужно достать отдельно R (5 бит), G (6 бит) и B (5 бит) uint8_t r = (color >> 11) & 0x1F; uint8_t g = (color >> 5) & 0x3F; uint8_t b = color & 0x1F; // 2. Коэффициент яркости // Если dist = 0 (в упор), factor = 1.0 (ярко). Если dist = 10, factor = 0.0 (черно). float factor = 1.0 - (dist / range); // 3. Сборка обратно // Умножаем каждый цвет на коэффициент и упаковываем назад в 16 бит return tft.color565(r * factor * 8, g * factor * 4, b * factor * 8); }
Использовал uint8_t worldMap[1024] для карты 32×32, это занимает всего 1 кБ RAM из доступных 80.
uint8_t worldMap[1024];
В игре генератор в случайном порядке расставляет кубы, которые Стив (как его обозвал мой сын) должен собрать начиная от 10 штук и отнести на базу, чтобы перейти на следующий уровень. Там количество кубов увеличивается на один, хотя можно добавить ещё что — нибудь, например стирание кубов по таймеру. Не успел найти — минус балл.
И да, непростительных заклинаний при написании и отладке кода до играбельного состояния было произнесено намного больше чем при сборке корпуса. В общем, проделана немалая работа и проще и даже дешевле было купить новую консоль, но… я пообещал сделать её сам. Подробный разбор кода пришельцев, алгоритма DDA, математики raycast, и всего кода проекта в следующей части. Если, конечно же, сообществу будет интересно.

sav13
А был бы ESP32, можно было бы DOOM запустить с вполне приличным FPS