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

Проектирование игры на языке Rust: Арены, кладовки, жилые массивы

Я решил разработать игру на Rust, но не буду ничего писать, пока не решу общие проблемы. Несмотря на то, что в предыдущих частях были достигнуты определённые успехи: Эти успехи быстро заканчиваются, когда мы переходим от единичных тестов к промышленному использованию. Показываю пример: Вот я создал объект GameObject с поведением BehaviourMove. Теперь я хочу этот объект поместить в список, потому что в игре мне нужен список объектов: let mut gmo_vec: Vec<GameObject> = Vec::new();
gmo_vec.push(gmo); Я создал вектор и положил туда объект. Теперь посмотрим такой вариант: Здесь я не стал создавать переменную gmo, а создал объект GameObject прямо "на лету" и передал его в push(). Так уже не работает. Рассмотрим, почему. Созданный объект GameObject успешно копируется в вектор, но у него есть один атрибут behaviour, в который помещается ссылка на объект BehaviourMove, а этот объект тоже надо создать. Он тоже создаётся, но после завершения метода push() освобождается, т.к. он временный и им ни
Оглавление

Я решил разработать игру на Rust, но не буду ничего писать, пока не решу общие проблемы. Несмотря на то, что в предыдущих частях были достигнуты определённые успехи:

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

Показываю пример:

Вот я создал объект GameObject с поведением BehaviourMove. Теперь я хочу этот объект поместить в список, потому что в игре мне нужен список объектов:

let mut gmo_vec: Vec<GameObject> = Vec::new();
gmo_vec.push(gmo);

Я создал вектор и положил туда объект.

Теперь посмотрим такой вариант:

-2

Здесь я не стал создавать переменную gmo, а создал объект GameObject прямо "на лету" и передал его в push().

Так уже не работает. Рассмотрим, почему.

Созданный объект GameObject успешно копируется в вектор, но у него есть один атрибут behaviour, в который помещается ссылка на объект BehaviourMove, а этот объект тоже надо создать. Он тоже создаётся, но после завершения метода push() освобождается, т.к. он временный и им никто не владеет. Ссылка на него становится недействительной. Следовательно, так делать нельзя.

Почему всё работает, когда мы сначала помещаем GameObject в переменную gmo? Именно потому что она владеет созданными данными и продолжает существовать после вызова push().

Это ещё не всё. Оно так работает, если написать код в главной программе, то есть в функции main(). Данные для переменной gmo создаются на стеке и остаются там, потому что программа работает и функция main() не завершается – её стек продолжает существовать, и следовательно все созданные там данные тоже существуют.

Но добавление в вектор нового объекта будет происходить не только в main(), а точнее даже вообще не там. Мы будем внутри какой-то функции обработки, которая обработает объект, и в процессе надо будет создать ещё один объект и поместить его в вектор. Например: пушка выстрелила, надо создать снаряд. На этот раз объект будет создаваться в стеке другой функции, из которой мы выйдем, и он разрушится. Поэтому так работать тоже не будет.

Данная проблема не относится конкретно к Rust. Это вообще не проблема языков, а проблема памяти. Я её решал в том числе в проекте GUI на языке C.

Но Rust будет дополнительно вставлять палки в колёса, поэтому надо действовать очень осмотрительно.

Remember – no unsafe

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

Выделения из кучи

Куча, в отличие от стека – глобально доступная область памяти, откуда можно выделять кусочки. Работать будет.

Для игр характерны сценарии, когда множество объектов на экране что-то делают и в процессе появляются и исчезают. Это происходит очень часто: например, пушка выпустила 10 новых снарядов, а тем временем 10 старых снарядов уже улетели за край экрана и должны быть удалены. Если для каждого игрового объекта использовать выделение из кучи, а потом освобождение в кучу, это будет крайне неэффективно. Ну, то есть, многие игры так и работают, но мы же здесь не затем, чтобы с этим мириться.

-3

Арены

Я уже использовал такие решения, но специально погуглил в поисках чего-то более оригинального, и не нашёл. Суть в том, чтобы не выделять память для каждого объекта из кучи, а сначала выделить большой блок памяти из кучи и уже из этого блока выделять память под однотипные объекты. Это повышает эффективность примерно до уровня того же стека.

Собственно, я это уже сделал для списка объектов Stage на основе вектора с заданным размером.

В Rust есть и специализированные механизмы для этих целей, они называются арены (тип Arena). Там всё просто: создал арену, затем можешь получать оттуда память и освобождать её. Есть разные нюансы, но я пока попробую реализацию своими силами, чтобы собрать побольше вероятных грабель.

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

-4

С чем придётся жить

Всякие там разные арены я буду называть словом "кладовка", чтобы оно ни с чем конкретным не ассоциировалось.

Если мы создали кладовку, то её придётся передавать во все функции, которые могут порождать новые объекты.

Ещё одна неприятность заключается в том, что в таких кладовках я собираюсь хранить объекты Behaviour разных типов: BehaviourWait, BehaviourMove и т.д. Значит, нужна отдельная кладовка под каждый тип объекта. Если их будет десять – значит, десять кладовок.

Зачем мне нужны именно списки объектов Behaviour, почему нельзя ссылаться на один объект? Потому что каждый объект Behaviour имеет собственное состояние. Вот летят 10 снарядов, и все летят под разными углами, значит у каждого свои параметры dx и dy, и поэтому каждому нужен отдельный объект.

Я сделаю к примеру структуру Storage с вектором для хранения внутри и пока обойдусь одним типом – нет смысла делать всё сразу.

-5

Итак, метод alloc_bhv_wait() должен класть в вектор BehaviourWait и возвращать &mut на этот элемент.

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

-6

Опять создаём GameObject на лету, но теперь размещаем behaviour в storage. Работает! Но только один раз. Пробуем сделать так второй раз, и конечно ошибка.

-7

Мы получили &mut из storage в первый раз, и теперь, пока этот &mut жив, весь storage будет заблокирован. Всё, приехали.

Можно... использовать индекс вместо ссылки. Так мы уже сделали для связи со StageObject, можно сделать и для Behaviour.

Кстати, я опять же погуглил в поисках более оригинального решения, и нет – всё-таки индекс это именно то, что все делают. И Arena работают тоже с индексами.

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

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

Назову этот компонент Pantry – натурально, кладовка. Ну а что, кто-то ведь выдумывает всякие арены, почему и мне нельзя.

-8

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

Финт ушами

Я сделал кладовку для хранения там объектов-поведений, и так как в GameObject теперь хранится индекс, возник другой случай.

Ранее при обработке я просто вызывал

gmo.behaviour.update(...)

Теперь у меня нет behaviour, его надо получить из кладовки. Получив его в виде ссылки &mut, я пытался что-то там вызывать, в результате опять зашёл в самоблокирующийся тупик.

После чего меня посетила другая идея. Я отдельно выделил параметры состояния поведений аналогично тому, как выделил отдельно GMOData для GameObject. Это теперь структуры BhvDataWait, BhvDataMove и т.д. И в кладовке я теперь храню не поведения, а только эти данные, потому что само поведение это просто некий метод, обрабатывающий данные. Этому методу не надо таскать данные с собой, их можно просто передать. Реализации трейта Behaviour теперь не содержат данных и являются пустыми структурами:

-9

Я по-прежнему храню ссылку на Behaviour в GameObject:

-10

Но сам объект Behaviour теперь можно нигде не хранить. Посмотрим такой код:

-11

Здесь создаётся объект GameObject, для которого создаётся на лету структура BehaviourWait и ссылка на неё, то есть после создания эта структура исчезнет и ссылка станет недействительной. Однако это работает.

Дело в том, что так как BеhаviourWait не содержит в себе никаких данных, создаваемый объект является полностью константным (если он вообще создаётся – данных-то нет), что компилятор понимает. И поэтому он помещает этот объект не в стек, а в статическую память программы. Т.е. объект "вшит" прямо в программу. Он константа. В любом месте программы, где будет использовано &BehaviourWait {}, ссылка будет вести на один и тот же константный объект.

Поправка из будущего: нет, оказалось, что данные константные объекты всё-таки не статические. Но их можно сделать таковыми, если указать в поле для хранения время жизни 'static.

И это работает лишь потому, что ссылка немутабельна. Компилятор знает, что объект меняться не будет. Если мы используем мутабельную ссылку, всё сломается.

Далее смотрим, нужна ли нам в принципе мутабельная ссылка на поведение? Нет! Оно пустое, ничего в себе не содержит, мутировать нечего. Значит, всё хорошо.

-12

Наконец добираемся до прототипа промышленной процедуры обработки объектов:

-13

Вызываем обработку поведения через gmo.bhv.update(), затем проверяем, не сошёл ли объект со сцены, и если да, то удаляем его со сцены (stage.remove_child()) и из списка объектов. Список объектов это вектор, и мы удаляем из него объект хитрым способом:

gmo_vec.swap_remove(i)

Здесь берётся последний элемент вектора и ставится вместо удалённого элемента. Поэтому данная операция всегда занимает О(1) времени, но нарушает порядок объектов в векторе. Это не страшно, потому что обработка от порядка не зависит – просто каждый объект надо обработать один раз.

Кстати, в этом листинге при удалении объекта я забыл удалить его данные поведения из кладовки. Забывать это конечно плохо, поэтому надо будет придумать что-то для централизованного удаления, которое будет чистить все связанные с объектом кладовки. Но это вроде как реализуемо без проблем.

Теперь пойдём посмотрим, что происходит в gmo.bhv.update():

-14

Метод получает ссылку на кладовку (точнее, на менеджер всех кладовок storage) и индекс хранимого состояния поведения. Здесь тонкий момент – так как каждое поведение работает со своим типом данных, я не могу передать в метод update() сами данные, потому что только само поведение знает, каким методом их надо получать из storage. Оно по индексу получает мутабельную ссылку на состояние поведения, и мутирует его как-то. В данном примере мы мутируем и данные поведения, и данные самого объекта GameObject. Просто для проверки.

Что в итоге?

В итоге я создам в игре несколько кладовок для разных типов данных. Для поведений, для отображаемых объектов (а то про них уже забыли), и для всего остального, что может понадобиться. Это несколько громоздко, но других вменяемых путей я не вижу.

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

Читайте дальше: