У GM328A есть энкодер с кнопкой, который может работать как 3 кнопки. Для тетриса использовалось несколько внешних библиотек:
Adafruit’s GFX library
Adafruit’s ST7735 LCD library
Matthias Hertel’s Rotary Encoder library
Программирование игры для GM328A было довольно сложной задачей. LCD и контакты поворотного энкодера использовали одни и те же выводы портов микроконтроллера, так что необходимо быстро переключаться между обновлением картинки и опросов сигналов энкодера. Для этого была организована очень быстрая запись данных в LCD, и все остальное время микроконтроллера было посвящено опросу энкодера. В противном случае игра могла пропустить повороты энкодера. Интерфейс SPI для LCD организован путем программного управления выводами (bit-bang), что не помогало в быстром выводе картинки. По этой причине пришлось обойтись без закраски квадратиков падающих фигур.
Для программирования GM328A использовалась среда разработки Arduino IDE, и в качестве целевой платы была выбрана Arduino Pro или Pro mini. Затем был выбран Atmega328 (3.3V, 8 МГц). Обратите внимание, что было выбрано питание 3.3V, хотя реально на плате использовалось 5V. Это нормально, поскольку в проекте тетриса не использовалось ADC, и не оказалось варианта 5V, 8 МГц. Таким способом удалось обойти лишнего редактирования файлов вне среды Arduino. Для выгрузки кода использовалась опция USBASP, Upload Using Programmer (Ctrl + Shift + U).
#include "game.h"
#include "sound.h"
void setup(void)
{
gameInit();
}
void loop()
{
gameTick();
drawBuffer();
waitInterrupt();
}
Цикл игры обновлялся главным образом с частотой 60 Гц, но не экран. Обновление экрана происходит только тогда, когда необходимо на нем что-то поменять, что из-за bit-bang реализации SPI занимает довольно много времени по сравнению с вычислением состояния игры. Функция gameTick() проверяет входы и вычисляет следующий кадр:
void gameTick(void)
{
side = getEncoderPos();
if (side == S_LEFT) tet.move(S_LEFT);
if (side == S_RIGHT) tet.move(S_RIGHT);
if (buttonWasPressed()) tet.rotate();
tet.update();
soundTick();
}
Энкодер используется для ввода: повороты вправо или влево перемещает падающую фигуру вправо или влево, а клик энкодера выполняет переворот фигуры. Опционально может быть подключена кнопка между выводами 1 и 3 сокета, которая работает как кнопка для сброса фигуры вниз.
К сожалению, энкодер должен быть запрещен, когда не используется. Энкодер совместно используется с экраном LCD, и активен только внутри функции waitInterrupt(). Эта функция вместе с обработчиком прерывания timer 1 выглядит следующим образом:
void waitInterrupt(void)
{
intFlag = 1;
enableEncoder();
while (intFlag)
{
encoder.tick();
}
disableEncoder();
}
ISR(TIMER1_OVF_vect)
{ // Обработчик прерывания Timer 1 (60 Гц).
TCNT1 = 65015; // Предварительная загрузка таймера.
intFlag = 0;
}
После того, как вычисление для текущего кадра закончено, происходит ожидание следующего кадра, в течение этого времени опрашивается энкодер. Очевидно, что когда используется LCD, некоторые повороты энкодера будут пропущены, и нет никакого способа для текущей схемы обойти эту проблему.
Чтобы максимально уменьшить время обновления, на LCD перезаписываются только те части экрана, которые необходимо обновить. Для примера рассмотрим функцию, перемещающую фигуру тетриса:
void Tetromino::move(int side)
{
...
// Часть кода, не относящаяся к этому пример, была удалена.
// Полный код см. в репозитории Github.
...
// Закрашивает старое положение фигуры черным цветом:
buffer[block[0]] = C_BLACK;
buffer[block[1]] = C_BLACK;
buffer[block[2]] = C_BLACK;
buffer[block[3]] = C_BLACK;
// Добавление фигуры в очередь рисования:
queueInsert(block[0]);
queueInsert(block[1]);
queueInsert(block[2]);
queueInsert(block[3]);
block[0] += side; block[1] += side; block[2] += side; block[3] += side;
// Заполнение новой позиции и добавление этого в очередь рисования:
buffer[block[0]] = color;
buffer[block[1]] = color;
buffer[block[2]] = color;
buffer[block[3]] = color;
queueInsert(block[0]);
queueInsert(block[1]);
queueInsert(block[2]);
queueInsert(block[3]);
}
Фигура состоит из 4 блоков, и эти блоки могут быть размещены только в 200 возможных позициях ("поле" тетриса занимает 10 × 20 блоков). Эти 200 позиций хранятся в переменной Buffer, которая содержит цвет каждого блока. Таким образом, старая позиция перемещающейся фигуры должна быть стерта, и должна быть закрашена цветом новая позиция, эти действия добавляются в очередь рисования. На каждом кадре функция рисования проверяет эту очередь на наличие данных, и если в ней присутствуют блоки, то они рисуются на экране:
// Рисуются только те блоки, которые перечислены в очереди. Эта функция
// вызывается на каждом кадре.
void drawBuffer(void)
{
while (!queueIsEmpty())
{
int i = queueRemoveData();
tft.drawRect(X0 + (i % 10)*BLOCK_SIZE, Y0 + (i / 10)*BLOCK_SIZE,
BLOCK_SIZE, BLOCK_SIZE, buffer[i]);
}
// Если игра закончена, то рисуется экран "game over".
if (gameover)
{
showGameOverScreen();
}
}
Остальные элементы экрана обновляются асинхронно. Всякий раз, когда они обновляются, рабочий поток игры обычно блокируется, так что это нормально. Например, когда строка стакана очищается, обновляется значение очков, все блоки выше должны переместиться на одну строку вниз, и на экране должна появиться картинка следующей фигуры.
Без канонической мелодии "Коробейники" игра была бы не завершенной, так что нужно как-то реализовать звук. Это было для автора довольно просто, потому что у него уже был опыт программирования музыки [9]. Для этой цели использовался выход PWM. Функция tone Arduino может генерировать прямоугольный сигнал в фона с помощью timer 2. Затем на каждом кадре делается проверка, нужно ли играть следующую ноту или делать паузу между нотами.
// Ноты мелодии и их длительность. Здесь 4 означает четверть ноты, 8 одну восьмую,
// 16 одну шестнадцатую, и т. д. Отрицательные числа используются для представления
// пунктирных нот, так что -4 означает пунктирную четверть ноты, т. е. четверть
// плюс восемнадцатая.
int melody[] =
{
// Основано на аранжировке https://www.flutetunes.com/tunes.php?id=192
NOTE_E5, 4, NOTE_B4, 8, NOTE_C5, 8, NOTE_D5, 4, NOTE_C5, 8, NOTE_B4, 8,
NOTE_A4, 4, NOTE_A4, 8, NOTE_C5, 8, NOTE_E5, 4, NOTE_D5, 8, NOTE_C5, 8,
NOTE_B4, -4, NOTE_C5, 8, NOTE_D5, 4, NOTE_E5, 4,
NOTE_C5, 4, NOTE_A4, 4, NOTE_A4, 8, NOTE_A4, 4, NOTE_B4, 8, NOTE_C5, 8,
...
};
// sizeof дает количество байт, каждое значение int составлено из 2 байт (16 бит).
// Два таких значения тратятся на ноту (высота и длительность), так что получается
// 4 байта на ноту.
int notes = sizeof(melody) / sizeof(melody[0]) / 2;
// Вычисление длительности целой ноты в мс (60s/tempo)*4 "битов"
int wholenote = (60000 * 4) / tempo;
int divider = 0, noteDuration = 0;
unsigned int index = 0;
unsigned long next=0;
void soundTick(void)
{
if (millis() > next)
{
if (index < notes * 2)
{
// Остановка генерации перед следующей нотой:
noTone(buzzer);
// Вычисление длительности ноты:
divider = melody[index + 1];
if (divider > 0)
{
// Обычная нота, с обычной обработкой:
noteDuration = (wholenote) / divider;
}
else if (divider < 0)
{
// Пунктирные ноты представлены отрицательными длительностями:
noteDuration = (wholenote) / abs(divider);
// Увеличение длительности наполовину для пунктирных нот:
noteDuration *= 1.5;
}
next = millis() + noteDuration;
// Мы проигрываем ноту в течение 90% от её длительности,
// оставляя 10% на паузу:
tone(buzzer, melody[index], noteDuration * 0.9);
index += 2;
}
else
{
index = 0;
next = millis();
}
}
}