«Однажды попробовав Rust, ты больше не вернешься»: разрабатываем игру для программистов на Bevy

«Однажды попробовав Rust, ты больше не вернешься»: разрабатываем игру для программистов на 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
) {
	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,
	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, из-за чего был беспощадно разрезан на множество систем катаной рефакторинга. Вот одна из них:

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,
}

#[derive(PartialEq, Debug, Clone)]
pub enum CodeBlockType {
    Function(Vec),
    Daemon,
    Virus,
}

#[derive(PartialEq, Debug, Clone)]
pub enum CodeBlockContent {
    Block(InnerCodeBlock),
    Lines(Vec),
}

#[derive(PartialEq, Debug, Clone)]
pub struct InnerCodeBlock {
    pub block_type: InnerCodeBlockType,
    pub content: Vec,
}

#[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, логику обучения и другое. Если вам интересна эта тема, в следующих материалах расскажу о них подробнее.

 

Источник

Bevy, rust, больше, вернешься, для, игру, на, не, однажды, попробовав, программистов, разрабатываем, ты

Читайте также