В подготовительных выпусках я разобрался с механизмами хранения игровых объектов:
Теперь можно начать делать конкретную игру, я назову её условно RDS.
Это ремейк игры Paratrooper 1982 года (на БК-0010 была переделка под названием Diversant).
Вкратце, игрок управляет неподвижной пушкой, у которой можно поворачивать ствол. По экрану летают вертолёты, которые сбрасывают парашютистов, и самолёты, которые бросают бомбы.
Задача игрока, управляя пушкой, сбивать всё что движется. Если парашютисты приземлятся с любой стороны пушки в количестве более 3-х, то они взорвут пушку и игра закончится. Также нельзя допустить, чтобы в пушку попала сброшенная бомба.
Игра хорошо подходит для демонстрации обработки списка объектов, потому что содержит разные объекты с разным поведением.
Хотя объектов много, типов поведений меньше. Например, движение снаряда пушки, бомбы и парашютиста – это всё один тип поведения "движение", просто с разными параметрами. А вот поведения вертолёта и самолёта – разные, потому что они должны ещё порождать парашютистов и бомбы.
Создание объекта
По сравнению с прошлыми частями, что-то я переименовал, какие-то методы и структуры слегка переделал, но всё осталось примерно так же по смыслу.
Единицей взаимодействия в игре является объект типа GameObject. Чтобы его создать, требуются следующие компоненты:
- GmoData – текущее состояние объекта. Не полиморфно, является частью структуры GameObject и поэтому внешнего выделения памяти не требует.
- StageObject – объект отображения на сцене, который хранит сцено-специфичные координаты, размеры, видимость и т.п. Ввиду того, что объекты на сцене расположены в своей иерархии для отрисовки, они хранятся отдельным списком, а в GameObject находится индекс сценического объекта sto_index. Также GameObject может вообще не иметь отображения.
- StageObject, в свою очередь, требует Drawable – трейт-объект, отвечающий за техническую процедуру отрисовки на экране. Полиморфен, хранится как ссылка в StageObject. Кладовка для него не нужна, про это напишу ниже.
- Behaviour – трейт-объект обработки поведения. Полиморфен, хранитcя как ссылка, выделения памяти не требует, потому что не обладает собственными данными и ссылка получается статическая.
- BhvData – данные для состояния поведения. У разных поведений разные наборы данных, поэтому в структуре хранить нельзя, хранятся в кладовке, ссылку хранить нельзя из-за конфликта владений, поэтому хранится их индекс в кладовке bhvd_index.
Почему объектам Drawable не нужно хранение в кладовке? Потому что их всех можно описать заранее. Например, объект для рисования снаряда пушки это белый квадрат размером 3*3 пиксела (да, пока я все объекты буду рисовать цветными прямоугольниками). Он не будет меняться, и если мы создадим 100 снарядов, все они будут ссылаться на один и тот же объект. Поэтому его можно сразу объявить статически:
Скорее всего, в процессе разработки я доберусь и до объектов отображения, которые динамически меняются. Тогда придётся их данные тоже хранить в кладовках.
Процесс сборки объекта GameObject довольно громоздкий, поэтому я сделал нечто вроде фабрики объектов, в которой можно вызывать метод получения готового объекта:
Посмотрим на сам метод spawn_shot():
Он формирует объект StageObject и помещает его на сцену, сохраняя его индекс в sto_index. Помещает в кладовку bhv_data и сохраняет индекс в bhvd_index. Возвращает объект GameObject со всеми готовыми атрибутами.
Замечу, что в метод передаётся ссылка на структуру Context, которая содержит все нужные компоненты: сцену, хранилище и саму фабрику. А также вектор gmo_new_vec, до которого доберёмся позже.
Я сделал структуру Context для того, чтобы не гонять много аргументов функций туда-сюда и не путаться в их порядке передачи.
Также отметим, что у StageObject есть экранные координаты x, y, но у GameObject они тоже есть. Зачем дублировать?
Вообще говоря, сначала я их сделал просто для примера, чтобы в структуре GameObject хоть что-то было. Потом решил, что можно их оставить. Координаты GameObject – не экранные. Это координаты виртуального пространства, в котором находятся игровые объекты, и оно может быть любого масштаба. Поведение, когда модифицирует объект, работает именно с этими координатами, так как оно ничего не знает и не должно знать об отображении.
Это уже потом координаты игровых объектов будут транслированы в координаты экранных объектов (в простейшем случае – просто скопированы). Да, здесь есть некоторое лишнее дублирование.
Удаление объекта
Перед тем как возрадоваться и добавить в список 100500 GameObject'ов, нужно сделать обработку случаев, когда они покидают пределы экрана. Тогда GameObject нужно удалять из списка, и также удалять со сцены связанный с ним StageObject, и данные поведения, которые хранятся в кладовке.
Здесь есть один нехороший нюанс. Так как только само поведение знает, какого типа у него данные, только оно и знает, в какой кладовке их надо искать. Аналогично, только само поведение знает, из какой кладовки надо удалять данные.
Поэтому в трейт поведения придётся добавить метод удаления данных free():
А потом реализовывать этот метод в каждом конкретном поведении:
Здесь BehaviuorMove знает, что его данные находятся в кладовке storage.pantry_bhvd_move.
Если будет 100 поведений, придётся написать 100 однотипных реализаций данного метода, либо придумывать что-то другое.
Теперь добавим метод освобождения в GameObject:
Он удаляет связанный StageObject со сцены и связанные данные поведения через само поведение.
Цикл обработки
Теперь можно взять вектор gmo_vec, в котором лежат какие-то объекты, и отправить в процедуру обработки:
Тут всё из прошлой части, с дополнениями:
- Cделано обновление экранных координат после обработки объекта. Для этого мы получаем из stage ссылку на объект StageObject и меняем его атрибуты x и y.
- Сделан механизм добавления новых объектов. Первоначально я планировал, что поведения, которые порождают новые объекты, будут размещать их в том же gmo_vec. Но так не вышло из-за перекрёстных заимствований, которые запрещает Rust. Поэтому здесь все новые объекты добавляются в отдельный вектор ctx.gmo_new_vec, который потом добавляется к основному вектору gmo_vec с помощью append(). Так оно и лучше, чтобы свежедобавленные объекты не обрабатывались сразу (они должны начать обрабатываться только в следующем кадре).
Далее я организую сворую из проекта Apple стандартный главный цикл программы на основе событий SDL2, убрав лишнее:
И в обработку нажатой клавиши вставляю добавление снаряда в список:
Теперь можно запустить программу и, нажимая на любую клавишу, "выстреливать" снаряды. Они будут лететь из координат посередине-снизу экрана (где типа находится пушка) вертикально вверх (пока без поворотов пушки). Цикл обработки работает, только надо следить за тем, чтобы не выпустить больше 128 снарядов одновременно – проверку на переполнение списка объектов я не сделал :)
Чтобы скриншот смотрелся лучше, я увеличил размер снаряда до 10*10:
Теперь я сделаю статические Drawable для вертолёта и самолёта. Это будут прямоугольники жёлтого и синего цвета. Для парашютов сделаю зелёный цвет, для бомб красный:
Пора заняться поведением вертолёта. Он летит по горизонтали и через какие-то интервалы бросает парашютистов. Данные поведения:
Здесь есть смещение по горизонтали dx, интервал выброса парашютистов interval, и счётчик интервалов cnt.
Пишем метод update() для поведения:
Здесь увеличивается счётчик cnt, и когда его значение достигает interval, мы обращаемся к методу фабрики spawn_chute(). Созданный объект помещается в ctx.gmo_new_vec.
Структуры нулевого размера
Стоит обратить внимание, что фабрика находится в контексте ctx, но мы не обращаемся к ней через ctx.factory. Уже пора привыкнуть, что Rust не даст вызвать метод ctx.factory.update() и передать в него &mut ctx, потому что ctx содержит factory и получается зацикленная ссылка.
Но можно получить копию фабрики:
let factory = ctx.factory
И вызвать update() у копии. Тогда зацикленной ссылки не получится. Это не тот же объект, это копия. Чтобы оно работало, нужно специальным способом пометить структуру GmoFactory:
Как видно, сама структура GmoFactory не имеет никаких атрибутов и поэтому имеет нулевой размер. Да, буквально ноль. Поэтому копирование по факту ненастоящее. Копировать нечего, и компилятор как-то это уберёт совсем, ну а у нас зато всё работает.
Порождение парашютиста и вертолёта
Вот парашютист:
Здесь порождается объект, аналогичный снаряду из пушки, просто у него другое отображение и другие данные движения (а поведение – то же самое BehaviourMove).
А вот вертолёт, у него уже поведение BehaviourCarrier:
Как видим, всё однотипно, просто немного разные параметры, и при желании можно сделать какой-то более универсальный метод.
Пора проверять!
Я пока не сделал логику, чтобы рандомно добавлять на экран вертолёты. Поэтому просто добавлю вручную в список два вертолёта, которые будут лететь на разных высотах в противоположных направлениях:
И всё!
Теперь, по идее, произойдёт следующее: вертолёты (жёлтые прямоугольники) полетят по экрану и примерно раз в секунду будут выбрасывать парашютистов (зелёные прямоугольники). Парашютисты будут падать вниз. Параллельно можно нажимать на клавишу и стрелять из пушки. То есть мы будем независимо обрабатывать поведение трёх типов объектов.
Пробуем:
Ура! Заработало!
Скажу честно, очень приятно это видеть. Система работает буквально автономно.
Дальше нужно сделать нормальную логику, картинки, поворот пушки и т.д. и т.п., но текущие мытарства пока ещё не закончены.
Нас ждёт следующий трудный этап: проверка столкновений объектов.
Ссылка на гитхаб-репозиторий:
Все сделанные в этой части изменения находятся в ветке part1.