
В сентябре 2026 я участвовал в восьмом конкурсе Nokia 3310 Jam, и моя работа под названием Trick Shot II заняла первое место из 51.
Задачей джема было создать игру в духе оригинальной Nokia 3310. Общие условия были такие:
Условный дисплей для отрисовки представляет монохромную область размером 84х48 пикселей.
Аудио должно соответствовать возможностям пьезодинамика Nokia.
Одновременно может воспроизводиться только одна аудиодорожка (в Nokia 3310 был всего один зуммер).
Управление ограничено 12 кнопками — ввод с помощью мыши возможен только при работе с виртуальной клавиатурой.
Регистрируясь на проекте, я планировал создавать игру на ebitengine — движке для 2D-игр, написанном на Go. Но недавно я также познакомился с tinygo — компилятором Go для популярных встраиваемых устройств.
Ещё до джема я собрал простенький игровой движок, в котором ebitengine используется для десктопа и Web-среды, а tinygo — для встраиваемых систем. Его исходники лежат на GitHub.
Кастомная плата Nokia
В самом начале проекта у меня ещё не было 3310. Тогда я просто соединил проводами любительский ЖК-дисплей PCD8544, пьезодинамик и кнопочный блок. Выглядело это так:

В этой конфигурации я использовал ту же распиновку, что и на кастомной плате, которую в итоге разработал для 3310.
Когда мне удалось раздобыть сам телефон, я его полностью разобрал, чтобы понять, как он устроен. Подробнее читайте в этой статье.
Вся механическая часть печатной платы — её контур, площадки под прижимные контакты, расположение светодиодов и кнопочной панели — я определял «на глаз» по миллиметровым делениям линейки 20 см.
Печатная плата, которую я спроектировал, приехала за три дня до начала джема. Ниже показана её первая версия, спаянная мной вручную.

На этом этапе я допустил пару ошибок:
Неверно измерил отступы в нижней части платы.
Пришлось обточить их дремелем (надев необходимые средства защиты).
Заказал плату толщиной 1,6 мм вместо 1 мм.
Корпус телефона не защёлкивается как следует.
В целом это не создавало проблем, но раздражало.
В последующих ревизиях платы я всё это предусмотрел. Её текущий дизайн можете посмотреть в репозитории.
Кастомный движок 3310
Чтобы в Trick Shot II можно было играть в браузере и на самом устройстве, я создал специальный движок.
Он включает две основных части:
API (папка
engine):библиотеки, которые использовались для создания игры.
Разделение под платформы (папка
platform):обрабатывает всю платформенную логику, чтобы движок одинаково работал как на встраиваемых устройствах, так и на десктопе.
API движка
Мне нравится, как устроен ebitengine, поэтому я взял его за основу для своего API движка. При создании игры он требует предоставления структуры, которая реализует:
type Game interface { Setup(Platform) Update() error Draw(canvas Canvas) error }
Setupдаёт возможность получить доступ к драйверам кнопочной панели (Keypad), звукового плеера (SoundPlayer), контроллера дисплея (Display) и прочему.В
Updateописана пошаговая логика игры.Drawпозволяет игре выполнять отрисовку на холсте (Canvas) размером 84x48.
API отрисовки
Этот API отвечает за стандартные задачи для вывода графики:
Текст.
Фигуры.
Спрайты.
Утилиты для дизеринга.
Отрисовка текста
Отрисовка текста реализуется с помощью tinyfont — библиотеки, которая является частью tinygo и может использоваться в любом Go-приложении. В ней есть генератор, который преобразует файлы ttf/otf в двоичные данные для размещения прямо в ROM программы.
При этом сами методы tinyfont были обёрнуты, чтобы соответствовать остальным API движка 3310.

Отрисовка фигур
В этом API доступны различные геометрические примитивы. Ниже показаны некоторые из них — часть с наложением интересных эффектов вроде градиента на окружности, полученного с помощью дизеринга.

Ещё картинки





API Triangle Fan и Triangle Strip основаны на функциях рисования примитивов из GameMaker, что бывает очень полезно при отрисовке произвольных фигур.
Отрисовка спрайтов
Как и в случае с текстом, движок спроектирован так, чтобы не загружать спрайты в RAM, а встраивать их напрямую в ROM. Это исключает использование форматов вроде GIF, PNG или JPEG, которые основаны на сжатии и для рендера требуют предварительной распаковки в RAM.
Вместо этого движок использует формат PBM, чтобы эффективно представлять спрайты в одном из двух видов:
Оттенки серого (с альфа-каналом):
отрисовывается с помощью дизеринга,
Монохромный:
одиночное монохромное изображение выводится сразу на холст,
для выражения цвета + прозрачности можно использовать два монохромных изображения.
Подробнее всё это расписано в статье «Efficient Image Embedding in TinyGo».
Титульный экран Trickshot является примером использования спрайта в оттенках серого, когда различные тона передаются с помощью дизеринга. Уровни в этой игре полностью сделаны из монохромных спрайтов.

Для встраивания PNG-файлов в проект можно использовать утилиту png2pbm, которая конвертирует их в оттенки серого или монохромные изображения. API движка умеет работать с лентами спрайтов, поэтому один спрайт может содержать сразу несколько кадров.
Дизеринг
Дизеринг — это техника, позволяющая имитировать полутона на экране с ограниченной палитрой с помощью чередования доступных цветов.
Чтобы определить, закрашивать пиксель или нет, движок сопоставляет его яркость (от 0 до 64) с пороговой картой на сетке 8x8 пикселей. Значение 0 соответствует полной прозрачности, а 64 — сплошному цвету.
В качестве пороговой карты используется матрица M8, описанная в Wikipedia.
Note interface { Frequency() float32 Index() note.Index Amplitude() uint8 Duration() time.Duration } type Sound interface { Reset() Next() Note Done() bool }
Движок также предоставляет набор готовых нот от C0 до B8. Но так как здесь у нас примитивный зуммер, ноты ниже C3 звучат просто как серия щелчков, поскольку в самом зуммере они не резонируют.
Инструмент midi2go преобразует MIDI-файлы в структуры данных для интерфейса Sound. Однако он рассчитан на то, что во входящем файле будет всего одна дорожка, поскольку ШИМ-драйвер зуммера поддерживает лишь один канал.
Команды ввода
Ввод пользователя обрабатывается интерфейсом Command:
type Command[T ~int] interface { Pressed(cmd T) bool Released(cmd T) bool Check(cmd T) bool PressedAny() bool ReleasedAny() bool CheckAny() bool }
Этот паттерн я уже обкатал на других игровых движках. Его суть в том, что при создании команд мы регистрируем метод check:
func (c *CommandImpl[T]) Register(cmd T, check func() bool)
Для команды можно зарегистрировать как один, так и несколько методов check. На их основе логика будет проверять, была ли кнопка нажата, отпущена или же активна в текущий момент.
Разделение под платформы
Эта часть нужна для того, чтобы игра работала одинаково на всех платформах.
В ней используется условная компиляция, которая позволяет собирать альтернативные реализации одного и того же кода под конкретные платформы.
Вот основные задачи, за которые отвечает этот компонент:
Оркестрация — обновление с нужной частотой кадров.
Отрисовка экрана 84x48.
Аудио.
Ввод пользователя.
Встраиваемые системы
Прошивка для встраиваемых систем собирается с помощью tinygo, и для её условной компиляции в начало файла добавляется следующая инструкция:
//go build tinygo
Игры обычно работают с фиксированной частотой кадров. За оркестрацию этого процесса отвечает простой цикл for, в котором мы:
обновляем драйвер клавиатуры для проверки пользовательского ввода,
вызываем
game.Update,обновляем аудиодрайвер,
вызываем
game.Draw,отрисовываем холст на дисплее PCD8544,
переводим микроконтроллер в сон до наступления момента следующего кадра.
Драйвер клавиатуры
Этот драйвер состоит из матрицы строк и столбцов, учитывающих все 16 кнопок Nokia 3310. Всего в схеме 4 входных столбца и 4 выходных строки, а сам алгоритм опроса выглядит так:
Для каждой выходной строки:
установить высокий уровень, оставив остальные строки на низком,
проверить каждый входной столбец; если на нём:
высокий уровень — кнопка на пересечении строки и столбца нажата,
низкий уровень — кнопка отпущена.
Аудиодрайвер
Аудиодрайвер управляет зуммером с помощью ШИМ-выхода, а программный интерфейс аудио позволяет задавать амплитуду и частоту.
Мы настраиваем период ШИМ-сигнала в соответствии с запрошенной частотой, а с помощью амплитуды варьируем коэффициент заполнения в диапазоне от 0 до 50%.
На практике амплитуда влияет на громкость звука весьма нелинейно.
Драйвер дисплея
Холст игры служит обёрткой для драйвера PCD8544. Это означает, что при большинстве вызовов API для отрисовки фактически мы подготавливаем драйвер PCD8544 к выводу изображения.
Аппаратная абстракция
На деле мы имеем два уровня абстракции. Первый — это разделение движка под десктопные и встраиваемые системы через директиву tinygo, а второй — это абстракция под конкретные аппаратные решения.
Главная идея в том, чтобы можно было использовать разные платы — например, если в будущем я решу отказаться от Raspberry Pi Pico и собрать более мощную плату на базе Teensy.
Некоторые опции заложены в сам движок, но самое главное, что через реализацию интерфейса board.Definition можно описать собственную конфигурацию платы:
type Definition interface { Initialize() Pcd() *Pcd Buzzer() *Buzzer Keypad() *Keypad }
После этого вызвать board.Set(input board.Definition).
Лично я за счёт этой абстракции использую более поздние ревизии платы 3310_pico. Например, в моей последней версии уже есть RGB-подсветка и функция измерения заряда батареи.
Десктоп
Десктопная версия движка основана на ebitengine и условно компилируется через добавление в начало файла следующей директивы:
//go build !tinygo
В ebitengine есть методы Update и Draw, которые движок 3310 использует для оркестрации.
В
Updateмы:обновляем драйвер клавиатуры,
вызываем
game.Update,вызываем
game.Draw.
В
Drawмы:применяем шейдеры для эмуляции ЖК-экрана,
отрисовываем изображение на холсте ebiten.
Драйвер кнопочной панели
В драйвере ввода можно задать нужную привязку клавиш под конкретное приложение. Для игр в стиле 3310 это весьма актуально, так как компьютерная клавиатура сильно отличается от кнопочной панели телефона.
Эмуляция аудио
В игровых движках аудио обычно реализуется через импорт файлов и их воспроизведение аудиоплеером.
Но в нашем движке аудио-API спроектирован под пьезоизлучатель и оперирует последовательностями нот разной продолжительности.
Поэтому вместо использования готовых аудиофайлов движок берёт эту последовательность нот и синтезирует звуковые волны, эмулируя работу зуммера. По сути, генерируется простой пилообразный сигнал, который затем пропускается через полосовой фильтр. Это позволяет получить звук, максимально похожий на реальный зуммер.
Эмуляция экрана
Экран эмулируется с помощью двух шейдеров, отвечающих за:
эмуляцию времени отклика (послесвечения): преобразует содержимое матрицы (84x48).
эмуляцию пиксельной структуры: растягивает матрицу (84x48) до разрешения экрана.
Результат вы можете видеть ниже вместе с отрисовкой рамки в виде корпуса телефона:

Чтобы убедиться в точности передачи размеров и визуальных эффектов, при работе над этими шейдерами я держал под рукой реальный дисплей PCD8544. Более подробно о нём я рассказывал в статье, посвящённой разборке Nokia 3310.
Десктопные расширения
Выше я показал изображение эмулированного дисплея с телефонной рамкой, за отрисовку которой отвечает отдельное расширение.
Расширения работают вне основного цикла программы, но позволяют делать наложение, выводить отладочную информацию и многое другое.


