Ретро-консоль в корпусе промышленного прибора: трассировка лучей на ESP8266
Новый старый корпус
Подарок для сына и утилизированный прибор
Не так давно мой пятилетний сын загорелся идеей получить игровую консоль. Поразмыслив, я пришел к выводу, что до полноценного девайса он еще не дорос… и решил сконструировать его самостоятельно. Проведя инвентаризацию домашних радиолюбительских запасов, я обнаружил TFT SPI дисплей диагональю 2.4 дюйма, плату джойстика и микроконтроллер ESP8266. Первым делом был набросан скетч в Arduino IDE, затем начались поиски подходящего корпуса… хотя хронологически всё происходило иначе. Сперва на барахолке была приобретена неисправная китайская приставка, однако после элементарной перепайки шлейфа экрана она неожиданно ожила, так что поиски продолжились. На глаза попался списанный на работе нормирующий преобразователь интерфейсов: после замеров выяснилось, что он идеально подходит на роль кастомного шасси.
Винтажный аккумулятор, термоклей и угловая шлифмашина
В импровизированных домашних условиях прошли финальная подгонка, разметка и слесарные работы. Поскольку плата джойстика немного превышала габариты корпуса, её пришлось безжалостно подрезать болгаркой. Гребёнки контактов с неё и с ESP8266 были демонтированы частично или полностью, а проводники МГТФ и перемычки распаяны напрямую для максимальной экономии пространства и уложены с ювелирной плотностью.
Когда дело долошло до автономного питания, в закромах отыскался рабочий аккумулятор от старого мобильного телефона Samsung. Батарея глубоко разрядилась, но после реанимации лабораторным блоком питания охотно приняла заряд. У товарища был позаимствован контроллер заряда TP4056, в коробке с мелочевкой нашелся миниатюрный тумблер — и началась финальная сборка. В качестве основного конструкционного фиксатора выступил проверенный поколениями мейкеров термоклей. Сдобрив процесс порцией экспрессивных выражений и технического мата, я наконец-то довел сборку устройства до конца.



Дефицит пинов и кадровая частота дисплея
Отдельного упоминания заслуживают аппаратные ограничения модуля NodeMCU на базе ESP8266 и методы их преодоления. Главная сложность заключалась в нехватке портов ввода-вывода, причем аналоговый вход и вовсе оказался в единственном экземпляре (A0). В результате джойстик задействовал лишь ось 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 и оптимизационные заветы Джона Кармака
Электроника была успешно спаяна (хотя повествование идет с конца, изначально всё тестировалось на макетке), после чего встал вопрос контента: что именно портировать или запрограммировать? Выбор пал на бессмертную классику — аркадный скролл-шутер Space Invaders. По ходу разработки простой отстрел геометрических фигур показался мне банальным, поэтому я добавил прогрессивную сложность, где скорость противников возрастает с каждым этапом. На каждом третьем уровне игрока поджидает босс с тремя подвижными разноцветными щупальцами и ретро-эффектом взрыва, сопровождающимся затухающими концентрическими окружностями и трелями пьезодинамика.
В аркаду были интегрированы система подсчета очков с сохранением рекорда в EEPROM, а также генерация полезных бонусов и одного штрафного предмета при уничтожении врагов. Для плавного перемещения спрайтов я применил классический трюк Джона Кармака — затирку объекта по старым координатам и прорисовку по новым с воспроизведением зигзагообразной траектории пришельцев. После отладки и устранения детских болезней…
Меня посетила навязчивая мысль, что создавать такое устройство ради примитивной 2D-аркады — непозволительное упущение, и загорелся идеей запустить на приставке полноценный рейкастинг (Raycasting). В конце концов, энтузиасты портируют DOOM на калькуляторы, чем ESP8266 хуже? Вооружившись исчерпывающим гайдом LodeV, я приступил к работе.


Рейкастинг, олдскульная оптимизация, пауза и стейт-машина
В первую очередь была спроектирована конечная автомат-система (стейт-машина) на три позиции: gameState == 0 отвечает за выбор в меню, gameState == 1 запускает loopRaycast, а gameState == 2 активирует loopInvaders. Управление выбором режимов возложено на джойстик, также появилась давно напрашивавшаяся функция паузы и аварийного выхода в главное меню по удержанию комбинации обеих клавиш.
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 это плохо, но здесь это защита от случайного нажатия
Я написал классический и стабильный движок трассировки лучей. Для повышения производительности рендеринга стен вертикальные колонки пикселей выводятся через одну. Реализован эффект тумана/фонаря (линейного затухания освещения): 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);
}
Под игровую карту размером 32×32 был выделен массив uint8_t worldMap[1024], расходующий всего 1 КБ оперативной памяти из доступных 80 КБ.
uint8_t worldMap[1024];
По игровой механике процедурный генератор случайным образом разбрасывает блоки, которые протагонист по имени Стив (так персонажа окрестил сын) должен собирать партиями от 10 штук и доставлять на базу для перехода на следующий этап. С каждым новым левелом требуемое количество ресурсов увеличивается, вдобавок можно внедрить таймер уничтожения кубов: не успел подобрать — теряешь очки.
Да уж, нецензурной лексики в процессе написания и отладки софта было произведено на порядки больше, чем при слесарных работах с корпусом. Проект потребовал колоссальных трудозатрат, и с финансовой точки зрения проще и дешевле было купить готовую китайскую консоль. Но слово было дано — сделать всё своими руками. Детальный разбор логики пришельцев, алгоритма цифрового анализа дифференциалов (DDA), математики рейкастинга и листинга всего проекта опубликую в продолжении, если, конечно, тема найдет отклик у сообщества.

The Witcher 3 установил новый рекорд по количеству игроков, а директор The Witcher 4 пообещал сюрпризы
Бессерверный детектив: создание ARG с пятью шифрами без регистрации и бюджета
В Gears of War E Day город будет разрушаться прямо по ходу кампании
В Японии полностью раскупили PS5 Pro несмотря на лотерейную систему от Sony
Джеймс Ганн уточнил, что сериал Народ против Гориллы Гродда выйдет после Человека завтрашнего дня
Предзаказ на линейку акриловых красок для миниатюр Warhammer Tone Pro откроется 10 октября
Продюсер Dynasty Warriors больше не хочет заниматься ремастерами серии
Sony ищет сторонние идеи для возрождения забытых франшиз PlayStation