Введение
На Хабре и на других площадках полно различных статей, связанных с разработкой на ПЛИС, но не сложно заметить, что большинство статей, как правило, несут реализацию сугубо технических идей. Реализация умножителей, сумматоров, мультиплексоров, различных интерфейсов, ЦОС, и многое другое… А как насчет игр?
Разумеется, я говорю не про эмуляцию приставок, а про реализацию непосредственно на одном из языков описания аппаратуры, например Verilog/SystemVerilog.
Я давно уже порывался реализовать одну из понравившихся игр, но понял, что чем интереснее игра, тем сложнее архитектура на ПЛИС, а чем сложнее архитектура, тем меньше вероятности, что ее кто-то захочет запустить у себя дома и поиграть. Одна из таких игр была DoodleJump, предшествующие ей статьи были про VGA и SDRAM, оставалось реализовать интерфейс управления и доделать функционал самой игры, а то главный герой только лишь прыгал по зеленым квадратикам. И тут появляется НО(!). Но я понял что код мой перегружен, ресурсов ПЛИС уже не хватало(речь про BRAM), а сам код становился слишком монструозным и с огромным количеством ассинхронщины, при желаемой частоте в 100МГц. В итоге я отказался от проекта, и оставил идею на потом.
Кстати можно посмотреть фото того, что получалось на тот момент:
Далее я сделал имитацию продвижения ГГ вверх, увеличил высоту прыжка, и при достижения верхней границы экрана начинал опускать объекты в низ.
Постановка задачи
В результате долгих размышлений у меня сложились некоторые условия той игры, которую бы я стал реализовывать:
-
Игра должна быть написана без привязке к конкретной ПЛИС(то есть чистый Verilog/SystemVerilog).
-
Игра должна быть сложнее чем теннис, где нужно «ракеткой» отражать летающий мячик.
-
Игра должна быть с различными режимами игры, для того чтобы было интересно ее не только запустить, но и поиграть с разными параметрами.
-
Игра должна (но уже не обязана), потреблять по минимуму ресурсов чтобы ее можно было собрать под большинство ПЛИС. К сожалению, однозначно этот пункт я не решил.
И буквально пару недель назад я наткнулся на игру ColorFlood на Android, игра не замысловатая, имеет несколько режимов, с простой графикой. И я понял, что это то, что подходит под мои требования.
Разумеется, начнем с ролика игры. На видео ниже показан игровой процесс, демонстрируется один из трех режимов игры, игра с ПЛИС:
И тоже видео на Ютубе: https://youtube.com/shorts/NrknB0c329g
Проект реализовывался под плату Nexys a7(должна быть совместима с Nexys 4 DDR). Сам он лежит на гитхабе: https://github.com/mifa1234/fpga_color_flood.
Реализация игры

Основной задачей стояла реализация фрейм буфера, без использования BRAM памяти. Использовать в упор массив размером в экран является тупиковым размышлением, так как планировал брать разрешение не ниже 640х480 пикселей, с форматом цвета RGB444. Так как игра ColorFlood подразумевает работу с квадратиками, то я нашел решение разбить весь монитор на квадратики по 32х32 пикселя, это как раз младшие 5 бит для счетчика ширины и высоты экрана. А старшие разряды можно использовать как индексы массивов. К тому же после разбиения экрана на квадраты по 32х32 пикселя, получается сетка 20х15 квадратов. А уже массив такого размера кажется более жизнеспособным. Я рассматривал и другие варианты 16х16, 8х8, но они слишком мелкие, да и в самой игре ColorFlood самое маленькое игровое поле имеет 12х12 квадратов, в моей реализации игровое поле будет 15х11.
Хорошо, с размером определился, теперь по аналогии с игрой в смартфоне нужно реализовать способы управления. По счастливой случайности при размещения игрового поля слева остается три свободных столбца, в средний столбец я разместил доступные цветные квадраты для выбора хода. Выбор хода осуществляется двумя кнопками: одна меняет выбор квадратика, подсвечивает это обводкой контура, а вторая кнопка подтверждает выбор и запускает ход. Для удобства игры я реализовал логику для двух кнопок управления выбором, вверх и вниз, но обе кнопки позволяют переключать выбор по кругу, в разном направлении.
Так как я не планировал делать поддержку шрифтов, а вывод прогресса нужно как-то делать, ведь не самому же квадратики пересчитывать, я реализовал прогресс бар внизу экрана. Эта зона делится на три части: слева к центру заполняется прогресс бар от первого игрока, справа к центру заполняется прогресс второго игрока (или ПК), в середине остается свободна серая область, которая постепенно уменьшается. В конце игры трекбар игрока, который победил начинает моргать обводкой.
В игре предусмотрена подсветка игрока чей ход. Рядом с углом одного из игроков будет моргать квадратик, призывающий к ходу, на фотографии выше такой отметки нету.
Ёлочку реализовал ради наступающего нового года)
Режимы игры
Как уже стало понятно у этой игры есть несколько режимов игры:
-
Одиночный режим – когда игрок пытается захватить все поле за минимальное количество ходов.
-
Игра с ПК – в данном случае корректнее говорить «игра с ПЛИС», в этом режиме участвуют два игрока, один игрок человек, второй игрок – алгоритмы ПЛИС, они по очереди выбирают цвет и ходят.
-
Игра в двоем – предполагается, что будет играть два человека, по очереди передавая друг другу управление. В этом режиме подсвечивается кто ходит.
Если с первым и третьим режимами понятно, то во втором режиме стоит уделить внимание алгоритму выбора цвета самой ПЛИС. В коде реализовано два уровня сложности, в первом случае выбирается первый доступный цвет, во втором случае сначала происходит поиск, где ПЛИС находит цвет с максимальным последующим присоединением квадратов. Этот выбор определяется параметром ENABLE_SIMPLE_AI.
Короткий пример:
Рассмотрим промежуточный этап игры, в левом верхнем углу начинает игрок, а в правом нижнем углу начинает ПЛИС. Следующий ход за игроком, но так как ПЛИС ходит почти мгновенно, то останавливаемся на пользователе. Поэтому предположим, что в следующий ход пользователь выберет синий цвет.
Тогда ПЛИС в случае ENABLE_SIMPLE_AI == 1, начнет считать количество соседствующих квадратов и выбирать с максимально выгодным вариантом. Таких будет два цвета, красный и голубой, тут сразу стоит отметить, что алгоритм не глубокий и ищет только соседей, поэтому голубых квадратов только 3, а не 4. Красных тоже будет насчитано три. И так как процесс идет итеративно, то последним проверяемым цветом будет зеленый (выбор идет сверху в низ от последнего положения), а это значит, что красный будет обладать последним максимальным значением. Почему не первое? А потому что я так реализовал логику и обошел вариант, когда все варианты выбора буду между собой могут быть равны и равны нулю (например, самое начало игры, когда из соседей только синий, а синий выбрать нельзя, так как он занят игроком).
Псевдо-рандом.
В игре зашит массив из рандомных значений от 0 до 4, всего 128 элементов. На основе этого массива строится игровое поле. Для того чтобы игровое поле было разным, я смещаюсь по этому массиву на некоторую величину. Эта величина делает +1 каждый раз, когда находится в состоянии сброса. Таким образом чисел вроде не много, всего 128, и вроде бы предварительно взяты из функции рандома на ПК, но позиция в этом массиве по сути своей случайна и зависит от того, как нажимаете кнопку сброса.
Параметры модуля game.sv
В ходе реализации игры я старался сделать ее такой, чтобы любой смог ее запустить у себя на плате, а также имел возможность безопасно кастомизировать игру. Для этого в самом модуле создал ряд параметров, расскажу про них по порядку.
BG_COLOR – цвет фона, этим цветом отрисовывается все, что не активно и не попадает под условие отрисовки. Настоятельно советую его выбирать отличным от других цветов.
COLOR_1..5 – это цвета квадратиков на игровом поле, всего их 5, значения по умолчанию брал такими чтобы можно было адаптировать под RGB111, взяв лишь старший бит.
Выглядит это так:

COLOR_TRACK_BAR – цвет незаполненной части трекбара.
COLOR_TRACK_BAR_PC – цвет трекбара для ПЛИС, либо второго игрока.
COLOR_TRACK_BAR_USER – цвет трекбара для первого игрока.
ANTI_BOUNCE_DELAY – параметр позволяет отработать дребезг кнопки. Сначала детектируется нажатие одной из кнопок затем срабатывает пауза, в пределах которой пользователь должен отпустить кнопку. Значение по-умолчанию подразумевает, что пользователь сделает одно короткое нажатие. Если у вас трясутся руки(например после праздников), либо кнопки сильно дребезжат то смело можете увеличивать это значение, исходя из того, что счетчик считает на частоте 25МГц.
DRAW_MARK – при установке в «1» отрисовывает точки в центре цветных квадратов, на игровом поле, это полезно в том случае, когда хочется самостоятельно посчитать число захваченных квадратиков.
Выглядит это так:

NEW_YEAR – елки) просто переберите значения 0,1,2 и посмотрите на результат.
ENABLE_SIMPLE_AI – включение простого искусственного интеллекта, даже не интеллекта, а алгоритма поиска. Я провел несколько игр и понял, что если самому в ответ не считать квадратики, то с включенным режимом легко проиграть, поэтому если даете ребенку, то установите его в ноль. Сам алгоритм я описал выше в статье.
ONLY_GAME_MODE_0 – установив в «1» включается только один режим игры. Режим одиночного игрока. Этот режим нужен в том случае когда ПЛИС достаточно маленькая, и при включенном данном режиме отключается вся логика связанная с участием других игроков, таким образом проект собирается в 5к лутов(на artix 7).
INDICATE_WHO_STEP – показывает того кто ходит. Полезная опция если играет два игрока, если в качестве второго игрока выступает ПЛИС, то для пользователя будет казаться всегда ходит он, ПЛИС делает ход слишком быстро. Поэтому с точки зрения экономии ресурсов этот параметр можно занулить.
Как запустить у себя?
Для тех, у кого имеется плата Nexys A7 достаточно скачать проект(https://github.com/mifa1234/fpga_color_flood) и открыть его в Vivado, все должно работать из коробки. Для тех, кто имеет другую отладку, вам необходимо взять файл game.sv из папки nexys_game_color_flood/nexys_game_color_flood.srcs/sources_1/new, и реализовать для него обертку, в котором реализуете следующее:
-
подключение VGA интерфейса к своему видео выходу, если это HDMI, то с использованием преобразователя, например такого как в Vivado, rgb2dvi. Сигналы: vga_r, vga_g, vga_b, vga_v_sync, vga_h_sync, и сигнал vga_pixel_valid, если делаете преобразование через rgb2dvi.
-
подачу тактового сигнала равного 25 МГц, в идеале, конечно, 25.175МГц чтобы соответствовало требуемому значению частоты пиксельного клока экрана с форматом 640х480 60Гц. Сигнал clk.
-
подключение сигналов управления кнопки select и ok, для управления ходом. Активный уровень высокий, то есть, когда кнопка не нажата она выдает ноль. Если у вас кнопки другие, то достаточно сделать инверсию. Сигналы key_ok, key_select_up, key_select_down. Можно не подключать один из сигналов select, если не хватает кнопок или вам лень, выбор будет цикличным.
-
подключить сигнал сброса, активный уровень низкий, то есть, когда кнопка не нажата, она возвращает единицу. Так же, как и с select и ok, вы можете просто инвертировать сигнал, чтобы добиться ожидаемого поведения сигнала. Сигнал rstn_pb.
-
Подключить значение mode_game, используется два бита, для него удобно использовать switch переключатели, то есть с фиксацией, но если у вас нет таких кнопок или вообще не хватает кнопок, то вы можете записать константу, в соответствии с режимом работы: 0 – одиночный режим, 1 – игра с ПЛИС, 2 – игра с другим игроком. Учтите что режим игры записывается в регистр только в состоянии сброса. Сигнал mode_game.
Кроме описанных выше сигналов есть выходные сигналы о статистике игры, их подключать не обязательно и можете использовать в любительских целях в рамках своего проекта.
-
result_game_each_step_valid – устанавливается на один такт в «1» после каждого хода каждого игрока. Указывает об актуальности значений статистики ниже.
-
result_game_valid – устанавливается в «1» в конце игры. Указывает на актуальность значений статистики ниже.
Статистика:
-
count_steps_player_1 – число сделанных ходов первым игроком.
-
count_steps_player_2 – число сделанных ходов вторым игроком либо ПЛИС.
-
result_player_1 – число захваченных клеток первым игроком, максимальное значение 165.
-
result_player_2 – число захваченных клеток вторым игроком либо ПЛИС.
-
result_game_mode – режим игры при котором происходит игра.
Что вы можете изменить в проекте безопасно:
-
Менять параметры в пределах указанных значений
-
Изменить значение переменной color_rand_pos на любое, это позволит сделать смещение для массива рандомных значений цветов
-
Изменить инициализацию массива color_pre_rand_arr значениями от 0 до 4 включительно. Если вас по какой-то причине не устраивает случайно распределение, то можете сделать другое. Может быть даже закодировать туда рисунок.
-
Изменить инициализацию массива image_array, в нем хранится картинка размером 32х32 пикселя. Именно в ней хранится та самая предновогодняя ёлочка.
Кстати, а вот и код на питоне для конвертирования картинки в код инициализации:
Примерно после 88 строки кода:
В переменную input_image указываете путь до картинки размером 32х32. Возможно понадобится полный путь.
В переменную output_sv указываете путь, где должен сгенерироваться файл, вместе с названием файла.
Так же убедитесь, что у вас стоят все необходимые библиотеки.
from PIL import Image
import numpy as np
def convert_to_rgb444(r, g, b):
"""
Конвертирует RGB888 в RGB444
Вход: 8-битные значения R, G, B (0-255)
Выход: 12-битное значение в формате RGB444
"""
# Берем старшие 4 бита каждого канала
r4 = (r >> 4) & 0x0F
g4 = (g >> 4) & 0x0F
b4 = (b >> 4) & 0x0F
# Объединяем в 12-битное значение: R(4 бита) | G(4 бита) | B(4 бита)
return (r4 << 8) | (g4 << 4) | b4
def image_to_sv_array(image_path, output_path):
"""
Конвертирует изображение в SystemVerilog инициализацию массива
Args:
image_path: путь к входному изображению
output_path: путь к выходному файлу SystemVerilog
"""
try:
# Открываем изображение
img = Image.open(image_path)
# Конвертируем в RGB формат если нужно
if img.mode != 'RGB':
img = img.convert('RGB')
# Получаем размеры изображения
width, height = img.size
print(f"Размер изображения: {width}x{height} пикселей")
# Создаем массив для хранения значений RGB444
# Массив будет иметь размер [height][width]
rgb444_array = np.zeros((height, width), dtype=np.uint16)
# Загружаем пиксели изображения
pixels = img.load()
# Заполняем массив в правильном порядке:
# Внешний цикл - по строкам (y, высота)
# Внутренний цикл - по столбцам (x, ширина)
for y in range(height):
for x in range(width):
# Получаем пиксель в координатах (x, y)
r, g, b = pixels[x, y]
# Записываем в массив [строка][столбец] = [y][x]
rgb444_array[y, x] = convert_to_rgb444(r, g, b)
# Генерируем SystemVerilog код
with open(output_path, 'w') as f:
f.write(f"// Автоматически сгенерированный файл инициализации изображенияn")
f.write(f"// Исходное изображение: {image_path}n")
f.write(f"// Размер: {width}x{height} пикселейn")
f.write(f"// Формат цвета: RGB444 (12 бит на пиксель)n")
f.write(f"// Массив объявлен как: image_array [height-1:0][width-1:0]n")
f.write(f"// Порядок инициализации: для каждой строки (y) все столбцы (x)nn")
# Объявление массива: [height-1:0][width-1:0]
f.write(f"reg [11:0] image_array [{height-1}:0][{width-1}:0];nn")
# Блок initial с инициализацией
f.write("initial beginn")
f.write(" // Инициализация построчно: сначала строка 0 (все столбцы), затем строка 1, и т.д.n")
# Инициализация по строкам: сначала все столбцы для строки 0, потом для строки 1 и т.д.
for y in range(height):
f.write(f"n // Строка {y}n")
for x in range(width):
value = rgb444_array[y, x]
# image_array[строка][столбец] = image_array[y][x]
f.write(f" image_array[{y}][{x}] = 12'h{value:03X};n")
f.write("endn")
print(f"Файл успешно создан: {output_path}")
print(f"Пример значения для левого верхнего угла (0,0): image_array[0][0] = 12'h{rgb444_array[0, 0]:03X};")
print(f"Пример значения для правого нижнего угла ({height-1},{width-1}): image_array[{height-1}][{width-1}] = 12'h{rgb444_array[height-1, width-1]:03X};")
except Exception as e:
print(f"Ошибка при обработке изображения: {e}")
# Пример использования
if __name__ == "__main__":
# Укажите пути к вашим файлам
input_image = "el2.png" # Замените на путь к вашему изображению
output_sv = "image_init.sv" # Имя выходного файла
image_to_sv_array(input_image, output_sv)
print("nСоветы по использованию:")
print("1. Убедитесь, что установлены необходимые библиотеки:")
print(" pip install pillow numpy")
print("2. Для больших изображений файл инициализации может быть очень большим")
print("3. В SystemVerilog подключите этот файл с помощью:")
print(" `include "image_init.sv"")
print("4. Порядок индексов в массиве: image_array[y][x], где:")
print(" - y: индекс строки (0 - верхняя строка, height-1 - нижняя строка)")
print(" - x: индекс столбца (0 - левый столбец, width-1 - правый столбец)")
Заключение
Надеюсь, после данной статьи выражение «играю с ПЛИС» получит еще одно значение.
Призываю всех хоть раз запустить и дать обратную связь, что все получилось, так же указать потребление ресурсов на вашей плис.
Мое потребление ресурсов ПЛИС при полнофункциональной сборке :
Напомню ссылку на гитхаб: https://github.com/mifa1234/fpga_color_flood
Автор: yamifa_1234


