Найти в Дзене
ZDG

Разработка игры RDS на языке Rust: Controller & Input

В предыдущей части был получен рабочий прототип игры со всеми необходимыми компонентами. Для продолжения работы игру надо структурировать, для чего нужны будут контроллеры. Эта тема уже освещалась в материалах про игру Apple, так что ничего нового я тут не скажу, но код из Apple немножко поменяю, с учётом новых достижений. Пробежимся по этой теме вкратце ещё раз. У игры есть несколько раздельных активностей: заставка, затем какое-то меню, затем собственно игра, затем экран окончания и т.д. Каждая такая активность это замкнутая в себе вещь, внутри которой есть собственный набор данных и собственная обработка пользовательского ввода. Например, нажатие на клавиатуре стрелки вверх работает по-разному, когда мы находимся в меню или в самой игре. Поэтому каждую активность обрабатывает свой контроллер. Собственно, ставим знак равенства между контроллером и активностью. Это позволяет разбить код на отдельные компоненты и работать над каждым независимо. Каждый контроллер получает события ввода.
Оглавление

В предыдущей части был получен рабочий прототип игры со всеми необходимыми компонентами.

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

Пробежимся по этой теме вкратце ещё раз.

Активности – Контроллеры

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

Поэтому каждую активность обрабатывает свой контроллер. Собственно, ставим знак равенства между контроллером и активностью. Это позволяет разбить код на отдельные компоненты и работать над каждым независимо.

Каждый контроллер получает события ввода. Но он не должен опознавать эти события по кодам нажатых клавиш. Он должен быть абстрагирован от физических устройств управления, потому что можно играть и джойстиком, и жестами рук, и сетевыми командами, и чем угодно.

Поэтому вводятся внутриигровые события. Это такие же события, как нажатия клавиш, только они уже преобразованы в единый и однозначный тип, то есть не "пробел нажат", а "пушка стреляет".

Обработка ввода

Преобразованием занимается компонент Input, который на входе получает события клавитуры/мыши, а на выходе формирует внутриигровое событие. Для разных игровых активностей компоненты Input могут иметь разную логику, поэтому они тоже делаются разными под каждую активность.

Итого, мы создаём некий контроллер и некий компонент Input, фильтруем поступающие события через Input, обрабатываем отфильтрованные события внутри контроллера, и ждём события-триггера для перехода в другую активность. Тогда мы создаём новый контроллер и новый компонент Input, и т.д.

InputEvent

Начнём с внутриигрового типа события InputEvent. Это будет перечисляемый тип:

Здесь собраны события для поворота пушки, стрельбы, выбора пунктов меню, завершения игры и т.п.

Хочу отметить, что управление в RDS будет сложнее, чем в Apple: в Apple просто нажималась клавиша, и человечек шёл. В RDS нужно поворачивать пушку, и также можно стрелять во время поворота пушки.

Это значит, что нажатие на клавишу включает некое состояние, а отпускание клавиши отключает его: когда нажата стрелка влево, пушка начинает поворачиваться влево (событие MoveLeft) и затем всё время поворачивается, пока стрелка влево не будет отпущена (событие Stop). Аналогично, при нажатии пробела пушка начинает стрелять и продолжает стрелять (событие Shoot), пока не будет отпущен пробел (событие StopShoot).

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

Input

Далее сделаем компонент Input. Он будет довольно сильно отличаться от реализации в Apple, потому что я нашёл другой способ его сделать.

Раньше Input был трейтом, чтобы можно было сделать разные реализации Input для разных активностей. Сейчас я реализации сделаю не трейтами, а просто отдельными функциями. У этих функций одинаковая сигнатура, и её для красоты можно определить как тип:

type EventTransform = fn(evt: &Event, input: &mut Input);

Теперь можно описать компонент Input без трейтов, в виде композиции:

-2

У него есть атрибут evt, где хранится отфильтрованное событие, и атрибут evt_transform, это указатель на функцию преобразования. То есть как трейт, но не трейт.

Я тут смешал процедурный стиль (функции) и ООП (структура), но это как раз то, чего мне раньше не хватало в Rust. Это не Java, здесь так можно и даже нужно.

Сделаю функцию преобразования, которая принимает событие типа "любая клавиша". Эта функция может использоваться для заставки игры, где управления как такового нет.

-3

Функция проверяет тип события, и если это "клавиша нажата", она устанавливает в input внутриигровое событие Continue, то есть продолжить.

Перед тем как делать остальные функции, завершу необходимые приготовления. У меня есть Input и есть функция преобразования. Теперь я могу создать новый объект Input, указав нужную функцию.

В самой структуре Input я могу сделать метод new() с параметром функции:

-4

Чтобы это ещё немного причесать, я делаю уже не совсем необходимую структуру InputBuilder, которая создаст один из типовых Input:

-5

А вот функция преобразования событий уже для самой игры:

-6

Она проверяет как нажатия, так и отжатия клавиш, и записывает в input соответствующие внутриигровые события.

Теперь допишем остальные методы Input:

-7

Метод get_event() возвращает хранящееся в Input внутриигровое событие. Метод set_event() обрабатывает поступившее внешнее событие, пропуская его через функцию преобразования.

Controller

Контроллеры были и остаются трейтами, как и в Apple, хотя их тоже можно переписать с использованием указателей на функции, что я, может быть, сделаю позже.

-8

Трейт контроллера имеет три метода.

  • begin() – подготовка к работе
  • run() – основной цикл работы
  • end() – завершение работы

Реализую контроллер для заставки игры, он попроще. У него есть собственные данные состояния:

-9

По задумке, данный контроллер должен вывести на экран логотип игры, и заставить его подпрыгивать под бодрую музыку (не знаю почему, но мне так представилось). sto_logo_index это индекс логотипа в списке объектов сцены, чтобы иметь к нему доступ. step и cnt это переменные, управляющие циклом подпрыгивания.

Пишем реализацию методов begin() и end():

-10

В begin() очищается сцена, на неё добавляется графический объект-логотип и мы сохраняем индекс этого объекта для дальнейшего доступа. В end() просто очищается сцена.

Теперь реализуем метод run(), который будет вызываться в каждом кадре игры:

-11

Используя сохранённый индекс, мы получаем от сцены размещённый на ней объект логотипа, и меняем его координаты, заставляя двигаться то вверх, то вниз. Метод возвращает true, что означает, что он продолжает работать. В дальнейшем он будет возвращать некое событие, по которому игра будет решать, что делать дальше.

Альтернативно можно было создать GameObject c поведением подпрыгивания, и прогонять его через стандартный цикл обработки поведений объектов. Но для такого простого контроллера это было бы избыточно, поэтому контроллер управляет сценическим объектом напрямую.

Я также сделал ControllerGame для основного цикла игры. Но так как я перенёс туда всю обработку объектов, которая раньше была в main(), он получился большой. К нему вернёмся чуть позже, а пока пойдём в main() и настроим использование контроллеров. Вот что делается перед началом главного игрового цикла:

-12

Я создал контроллер TitleController, создал специфический Input для него, и вызвал метод begin() у контроллера.

Далее идёт игровой цикл, из которого я поубирал всё лишнее:

-13

Здесь делается предварительная фильтрация событий: если это нажатая (Event::KeyDown) или отжатая (Event::KeyUp) клавиша, они передаются в input.set_event(), который их преобразует в соответствии со своей логикой.

Затем вызывается controller.run() и... всё! Контроллер сам всё сделает.

После запуска программы я получаю такую прыгающую заставку:

-14

Конечно, на картинке этого не видно, но она действительно прыгает и выглядит очень глупо.

Теперь вернёмся к ControllerGame.

Я перенёс в него всё, что было в игре: список объектов, обработку поведений объектов, коллайдер и прочее. Соответственно, структура контроллера получилась такая:

-15

Состояние контроллера описывают атрибуты:

  • shoot_cooldown – таймер задержки между выстрелами пушки
  • moving_dir – в какую сторону движется пушка
  • shooting – стреляет ли пушка

Далее я покажу только обработку событий в методе run():

-16

Контроллер устанавливает состояния moving_dir и shooting в зависимости от полученных событий. Больше события нигде не участвуют, и контроллер переходит к обработке своих текущих состояний:

-17

Пока moving_dir не равно нулю, пушка будет поворачиваться в соответствующем направлении (экранный объект пушки мы получаем от сцены через сохранённый индекс sto_shaft_index).

Дальше проверяем значение кулдауна стрельбы, и если можно стрелять, и есть состояние shooting, то делаем выстрел (весь этот код ранее был в main()):

-18

Ну и дальше обрабатываем поведения объектов, коллизии и пр.

Теперь в main() я вместо ControllerTitle инициализирую ControllerGame с соответствующим Input:

-19

И после запуска игры мы видим, как работает новый контроллер:

-20

Теперь и пушку можно вращать во время стрельбы, и частота выстрелов ограничена кулдауном.

Смена активностей

Каждый контроллер должен возвращать что-то вроде статуса, или события – продолжает ли он работать, или надо сменить активность на другую. Обрабатывать эти статусы будет main(), как главный супервизор активностей.

Сделаю перечисляемый тип для событий, возвращаемых контроллером:

-21

Изменю метод run() для контроллера ControllerTitle, чтобы он после нажатия клавиши возвращал событие EndTitle:

-22

Аналогично поменяю метод для ControllerGame (он будет завершаться при нажатии Esc), и добавлю управление контроллерами в main():

-23

Здесь мне придётся сразу создать два контроллера controller_title и controller_game, так как они всё равно будут переиспользоваться, а переменная controller будет указателем на один из них. Так же сделано в Apple.

Далее в главном цикле я проверяю, что вернул контроллер:

-24

Если это было событие EndTitle, значит закончился контроллер заставки, и я завершаю текущий контроллер и переключаю его на ControllerGame. Если было событие EndGame, то я заканчиваю игру совсем (можно снова переходить на заставку, но это уже вопрос организации).

Ну вот, теперь можно запустить игру, увидеть прыгающую заставку, нажать любую клавишу и начать играть. А нажав Esc, закончить игру.

Мне осталось сделать шрифты, меню, добавить поведения для самолётов и бомб, музыку, перерисовать графику... работы непочатый край. Но край виден.

Код выложен на гитхаб в ветку part7:

GitHub - nandakoryaaa/rds-game at part7

P.S.

Можно заметить, что из триады MVC я использовал только контроллер. Модель хранится как атрибуты состояния контроллера, а представление это сама сцена, куда контроллер что-то добавляет.