«Once you go Rust, you never go back»: создаем игру для программистов на Bevy

от автора

Привет, Хабр! В прошлой статье я рассказал о своем переходе в геймдев и моей концепции «хакерской» игры. Здесь же сосредоточился именно на разработке, а также на инструментах Bevy и Rust, которые использовал для игрового движка. Интересно узнать из первых уст, как создаются отечественные инди-игры? Тогда добро пожаловать под кат.

Используйте навигацию, если не хотите читать текст полностью:

С чего начал
UI
Gameloop
IDE
Итоги

С чего начал


Перед тем, как приступить к разработке, немного расскажу о самом проекте. HackeRPG — это экшн-игра с элементами RPG. В ней пользователь управляет персонажем с помощью кода, чтобы сражаться с вирусами, багами, троянами и другими «вредителями».

Поскольку механика предполагает знание программирования, игра рассчитана на узкую аудиторию или, другими словами, «на своих», специально для них я добавил пасхалки и привычные разработчикам фичи.

Инструменты

Чтобы упростить дальнейшее понимание решений, которые я реализовал в проекте, рассмотрим движок Bevy. Его главная особенность — ECS (Entity component system), где

  • Component — любая сущность игре, например, спрайт, свет или текст,
  • Entity — контейнер, куда мы складываем все компоненты,
  • System — действия с компонентами, которые выполняются в бесконечном цикле.

Визуализация ECS.

Дополнительно в движке реализованы состояния для ограничения работы систем, события для разового вызова систем и ресурсы для хранения данных. Последний не связан с какими-либо сущностями и имеет только один экземпляр. Формально его можно увеличить с помощью компонентов, но в некоторых ситуациях этот вариант удобнее.


UI


В начале работы мне нужно было определиться с разработкой визуального стиля. Для пользовательского ввода я выбирал mouseless-подход: каждое меню в игре — симуляция терминала, а основной gameloop (игровой цикл) — ввод текста в консоль. Ну и, разумеется, весь текст должен обязательно быть зеленым. Куда без заезженной хакерской эстетики?

И не говорите мне, что ваш рабочий день выглядит не так.

Сама по себе задача несложная, но ECS делает ее организацию неудобной. Приходится создавать дополнительные абстракции, которые не нужны в других подходах. А это — большое количество строк кода, нюансов и ошибок. Однако после некоторых оптимизаций, разделения функционала и модулей, можно добиться вполне сносной архитектуры.

Для этого я создал системы, которые запускаются один раз и создают нужные нам компоненты:

fn setup_menu_system( mut commands: Commands,  game_assets: Res<GameAssets> ) { commands.spawn( NodeBundle { style: Style { width: Val::Percent(100.), height: Val::Percent(100.), flex_direction: FlexDirection::Column, justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() } ).with_children(|parent| { spawn_console( &game_assets, parent, ); }); } 

Структура 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<ReceivedChar>, 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<Input<KeyCode>>, mut console_query: Query<(&mut TextInput, &mut TextCaret)> mut ev_exit: EventWriter<AppExit> ) { 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, из-за чего был беспощадно разрезан на множество систем катаной рефакторинга. Вот одна из них:

fn handle_current_move_command_system( mut commands_query: Query<(&mut PlayerCommands, &mut Movable)> ) { for (mut commands, mut movable) in command_query.iter_mut() { match &commands.current { Move(x,y) => handle_move_command(x,y,&mut movable), _ => {} } } } 

В функции мы проверяем, является ли move текущей командой. Если ответ положительный, то начинаем ее обрабатывать. В этом нам помогает шаблонный код: он убирает «ад» из огромного количества Query, которые нужны при обработке всех команд в одной функции. Практика показала, что это решение достаточно удобное.

Результат.

В результате мне удалось реализовать управление персонажем с помощью команд. При этом можно добавить новые, используя при парсинге ключевые слова и элементы в Enum, а также систему, которая будет эту команду обрабатывать. Довольно неплохо и почти чисто.

IDE


Теперь настало время прыгнуть в кроличью нору — добавить внутриигровую IDE.
Выше я описал основные фичи, которые использовал для разработки терминала. Далее задача весьма тривиальна — настроить рекурсивный парсинг. Но вот проблема: Rust не дружит с использованием рекурсии в регулярных выражениях, а значит решение только одно.

Для этого пришлось комбинировать парсинг и использовать регулярные выражения с рекурсией на основе данных. Это помогло мне расположить команды if в if и for в for. В результате из строки получилась структура CodeBlock:

#[derive(PartialEq, Debug, Clone)] pub struct CodeBlock {     pub name: String,     pub block_type: CodeBlockType,     pub content: Vec<CodeBlockContent>, }  #[derive(PartialEq, Debug, Clone)] pub enum CodeBlockType {     Function(Vec<String>),     Daemon,     Virus, }  #[derive(PartialEq, Debug, Clone)] pub enum CodeBlockContent {     Block(InnerCodeBlock),     Lines(Vec<String>), }  #[derive(PartialEq, Debug, Clone)] pub struct InnerCodeBlock {     pub block_type: InnerCodeBlockType,     pub content: Vec<CodeBlockContent>, }  #[derive(PartialEq, Debug, Clone)] pub enum InnerCodeBlockType {     If(String),     For(ForLoopInnerCodeBlock),     While(String), }  #[derive(PartialEq, Debug, Clone)] pub struct ForLoopInnerCodeBlock {     pub variable_name: String,     pub from: PlayerCommandInput,     pub to: PlayerCommandInput, } 

Каждый блок кода имеет один из трех типов: Function, Daemon или Virus. Они хранят содержимое «тела» в векторе CodeBlockContent. При этом внутренний контент может быть либо строками, которые будут парсить command_from_string, либо структурами InnerCodeBlock. Последние похожи на CodeBlock, но имеют типы If, For или While.

Если текущих сущностей мне будет недостаточно или я захочу подключить новые, то добавлю элементы в перечисление, а ключевые слова в логику парсинга.

Так выглядит IDE в игре.

Итоги


Цель этой игры — помочь людям улучшить навыки программирования и привить к нему любовь. В какой-то степени цель оказалось достигнутой: во время разработки я неплохо прокачал свои навыки и по-настоящему полюбил Rust. Теперь, когда это возможно и имеет смысл, стараюсь отдавать ему предпочтение.

А что насчет Bevy? Он хорошо себя показал. Конечно, были и неприятные баги, которые я пофиксил в следующей сборке, не совсем удобные решения, а также миграция при переходе на новую версию, но все это перекрывает:

  • удобство разработки,
  • качество итогового продукта,
  • маленький размер сборки.

Весь текст — это лишь небольшой срез проделанной работы. Помимо этого есть еще интересные решения, которые я создал в рамках этого проекта. Например, нюансы парсинга, использование ресурсов для запуска кода, древо прокачки в стиле Git, логику обучения и другое. Если вам интересна эта тема, в следующих материалах расскажу о них подробнее.


ссылка на оригинал статьи https://habr.com/ru/articles/788694/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *