Привет, Хабр! В прошлой статье я рассказал о своем переходе в геймдев и моей концепции «хакерской» игры. Здесь же сосредоточился именно на разработке, а также на инструментах Bevy и Rust, которые использовал для игрового движка. Интересно узнать из первых уст, как создаются отечественные инди-игры? Тогда добро пожаловать под кат.
Используйте навигацию, если не хотите читать текст полностью:
Перед тем, как приступить к разработке, немного расскажу о самом проекте. HackeRPG — это экшн-игра с элементами RPG. В ней пользователь управляет персонажем с помощью кода, чтобы сражаться с вирусами, багами, троянами и другими «вредителями».
Поскольку механика предполагает знание программирования, игра рассчитана на узкую аудиторию или, другими словами, «на своих», специально для них я добавил пасхалки и привычные разработчикам фичи.
Инструменты
Чтобы упростить дальнейшее понимание решений, которые я реализовал в проекте, рассмотрим движок Bevy. Его главная особенность — ECS (Entity component system), где
Component — любая сущность игре, например, спрайт, свет или текст,
Entity — контейнер, куда мы складываем все компоненты,
System — действия с компонентами, которые выполняются в бесконечном цикле.
Визуализация ECS.
Дополнительно в движке реализованы состояния для ограничения работы систем, события для разового вызова систем и ресурсы для хранения данных. Последний не связан с какими-либо сущностями и имеет только один экземпляр. Формально его можно увеличить с помощью компонентов, но в некоторых ситуациях этот вариант удобнее.
UI
В начале работы мне нужно было определиться с разработкой визуального стиля. Для пользовательского ввода я выбирал mouseless-подход: каждое меню в игре — симуляция терминала, а основной gameloop (игровой цикл) — ввод текста в консоль. Ну и, разумеется, весь текст должен обязательно быть зеленым. Куда без заезженной хакерской эстетики?
И не говорите мне, что ваш рабочий день выглядит не так.
Сама по себе задача несложная, но ECS делает ее организацию неудобной. Приходится создавать дополнительные абстракции, которые не нужны в других подходах. А это — большое количество строк кода, нюансов и ошибок. Однако после некоторых оптимизаций, разделения функционала и модулей, можно добиться вполне сносной архитектуры.
Для этого я создал системы, которые запускаются один раз и создают нужные нам компоненты:
Структура commands добавляет на экран элемент NodeBundle с дочерними элементами, их создает моя собственная функция spawn_console.
Отдельно отмечу удобство работы с HUD-слоем: в Bevy она реализована на основе концепции FlexBox, которая будет привычна любому, кто более или менее знаком с веб- и фронтенд-разработкой.
Еще один важный нюанс — зачастую система отображает одним и тем же компонентом input и output. Например, есть программа:
Say hello
>hello█
где первая строка — output, а вторая (кроме первого символа и каретки) — input. При наличии такого меню она использует один текстовый элемент, который выводит обе строки. Поэтому пользовательский ввод хранится в отдельном компоненте, а система, которая выводит текст на экран, «приклеивает» его на лету. Такой подход позволяет удобно настраивать логику каретки и истории ввода.
Для вывода текста в нашем «терминале» я использую следующие системы:
fn menu_output_system(
mut console_query: Query<(&mut Text, &TextInput)>
) {
if let Ok((mut output, input)) = console_query.get_single_mut() {
let output_text = get_menu_output();
output.sections[0].value = output_text + text_input.text.as_str();
}
}
где Query получает сущности, у которых присутствуют указанные компоненты. TextInput — кастомный компонент — хранит данные о вводе и автодополнении, а функция get_menu_output генерирует строку для вывода.
Осталось добавить обработку ввода и MVP терминала готов! Ниже — две системы, которые я использовал:
fn menu_char_input_system(
mut ev_char: EventReader,
mut console_query: Query<(&mut TextInput, &mut TextCaret)>
) {
for ev in ev_char.read() {
if ev.char.is_control() {
return;
}
if let Ok((mut input, mut caret)) = console_query.get_single_mut() {
insert_char_at_caret_position(
&mut input,
ev.char.to_string(),
&caret
);
update_caret_on_input(&mut caret);
}
}
}
fn menu_control_input_system(
keyboard: Res>,
mut console_query: Query<(&mut TextInput, &mut TextCaret)>
mut ev_exit: EventWriter
) {
if let Ok((mut input, mut caret)) = console_query.get_single_mut() {
if keyboard.just_pressed(KeyCode::Return) {
match input.text.as_str() {
"0" => ev_exit.send(AppExit),
_ => {}
}
input.text = "".to_string();
reset_caret(&mut caret);
}
}
}
Первая функция обрабатывает событие ReceivedChar и отправляет полученный символ в TextInput по позиции каретки, а после обновляет его положение. Вторая считывает нажатия клавиши Enter и выходит из приложения, если введен ноль. С помощью дополнительных состояний, которые характеризуют текущий экран, можно реализовать любую навигацию, обработку ввода и отображение информации.
Результат проделанной работы.
На данный момент состояния в Bevy не сохраняют информацию, вместо этого представляют примитивное перечисление. Это вынуждает связывать их с ресурсами или компонентами, которые хранят их данные. Насколько мне известно, в ближайшее время это не поменяется, поэтому нам придется с этим смириться.
В остальном нет ничего сложно: отображение текста, анимация спрайтов, движение — типичный контент для туториалов. Поэтому сразу перейду к именно «прогерским» фичам.
Gameloop
Главная фича игры — управление персонажем с помощью команд. Рассмотрим подробнее, как это сделать.
Для ввода текста используем команду menu_char_input_system. Убираем из названия слово menu и заставляем его работать не только на главном экране, но и в самой игре. К сожалению, с control_input_system так не получится, нужно писать другую. Опустим шаблон кода (boilerplate) и перейдем сразу к сути:
...
if let Some(command) = command_from_string(input.text.to_string()) {
player_commands.queue.push(command);
}
…
Теперь, когда нажимаем Enter, начинаем парсить команду на основе нашего input и, в случае успеха, добавляем ее в очередь. Выглядит это так: мы выполняем парсинг внутри функции command_from_string. Система запускает по одной команде из очереди, если текущая не в процессе.
fn handle_command_queue_system(
mut commands_query: Query<&mut PlayerCommands>
) {
for (mut commands) in commands_query.iter_mut() {
if check_in_progress(&commands) {
continue;
}
set_current_command(&mut commands_query);
}
}
Можно заметить, что мы используем for и iter_mut() вместо if let Ok и get_single_mut(). Таким образом обрабатываем компоненты, которых в игре может быть несколько.
Этот сниппет — программное олицетворение моего оптимизма, который внушает мне, что проект найдет интерес и признание, и появится запрос на мультиплеер. В остальном происходит проверка, есть ли текущая активная команда с помощью check_in_progress, и если ее нет, переносит команду из вектора queue в переменную current.
Далее управление принимает большое количество систем, которые обрабатывают конкретную команду. Изначально вся эта конструкция представляла собой один большой match, из-за чего был беспощадно разрезан на множество систем катаной рефакторинга. Вот одна из них:
В функции мы проверяем, является ли move текущей командой. Если ответ положительный, то начинаем ее обрабатывать. В этом нам помогает шаблонный код: он убирает «ад» из огромного количества Query, которые нужны при обработке всех команд в одной функции. Практика показала, что это решение достаточно удобное.
Результат.
В результате мне удалось реализовать управление персонажем с помощью команд. При этом можно добавить новые, используя при парсинге ключевые слова и элементы в Enum, а также систему, которая будет эту команду обрабатывать. Довольно неплохо и почти чисто.
IDE
Теперь настало время прыгнуть в кроличью нору — добавить внутриигровую IDE.
Выше я описал основные фичи, которые использовал для разработки терминала. Далее задача весьма тривиальна — настроить рекурсивный парсинг. Но вот проблема: Rust не дружит с использованием рекурсии в регулярных выражениях, а значит решение только одно.
Для этого пришлось комбинировать парсинг и использовать регулярные выражения с рекурсией на основе данных. Это помогло мне расположить команды if в if и for в for. В результате из строки получилась структура CodeBlock:
Каждый блок кода имеет один из трех типов: Function, Daemon или Virus. Они хранят содержимое «тела» в векторе CodeBlockContent. При этом внутренний контент может быть либо строками, которые будут парсить command_from_string, либо структурами InnerCodeBlock. Последние похожи на CodeBlock, но имеют типы If, For или While.
Если текущих сущностей мне будет недостаточно или я захочу подключить новые, то добавлю элементы в перечисление, а ключевые слова в логику парсинга.
Так выглядит IDE в игре.
Итоги
Цель этой игры — помочь людям улучшить навыки программирования и привить к нему любовь. В какой-то степени цель оказалось достигнутой: во время разработки я неплохо прокачал свои навыки и по-настоящему полюбил Rust. Теперь, когда это возможно и имеет смысл, стараюсь отдавать ему предпочтение.
А что насчет Bevy? Он хорошо себя показал. Конечно, были и неприятные баги, которые я пофиксил в следующей сборке, не совсем удобные решения, а также миграция при переходе на новую версию, но все это перекрывает:
удобство разработки,
качество итогового продукта,
маленький размер сборки.
Весь текст — это лишь небольшой срез проделанной работы. Помимо этого есть еще интересные решения, которые я создал в рамках этого проекта. Например, нюансы парсинга, использование ресурсов для запуска кода, древо прокачки в стиле Git, логику обучения и другое. Если вам интересна эта тема, в следующих материалах расскажу о них подробнее.