После разработки игры Apple я зарёкся что-либо писать на Rust, потому что это банально неэффективно. У меня в коде нет утечек памяти, чтобы Rust от них оберегал. А вот написание кода становится настоящей пыткой. Нашёл хорошее обобщение опыта (это значит, не стоит понимать его буквально):
Умеете ли вы конструировать сложные библиотеки или приложения с нуля так, чтобы после 10 тысяч строк кода не получить ужасную красную волнистую линию, которая потребует переделать весь проект?
Ужасная красная волнистая линия:
Но всё же мне не давал покоя этот прошлый опыт, как будто я что-то не понимаю. К тому же некоторые практики из Rust я перенёс в другие языки, так что это оказалось чем-то полезно.
Поэтому я решил дать Rust ещё один шанс и сделать очередную простую игру, но я не буду ничего писать, пока не разложу всё по полочкам.
В данном случае меня интересует реализация графической подсистемы и то, что за ней потянется.
Рассмотрим абстрактную игру. В ней могут быть десятки и сотни различных игровых объектов, которые присутствуют на экране и взаимодействуют.
Значит, первое, что нужно, это список для хранения игровых объектов.
Далее, на экране могут появляться новые объекты, а также исчезать существующие. Значит, этот список должен динамически меняться.
Поэтому первое, о чём я думаю, это компонент
Stage (сцена)
Он хранит список объектов и предоставляет интерфейс для их добавления и удаления, чтобы не заботиться о конкретной реализации списка.
Также Stage обладает представлением об игровом экране. Он знает его размеры и знает координаты и размеры каждого объекта в списке. Это позволяет ему определять, какие объекты видны, а какие нет.
Кроме того, Stage поддерживает иерархию объектов – чтобы они рисовались в определённом порядке, перекрывая друг друга правильно.
Далее нужен рендер.
Я использую графическую библиотеку SDL2, но если абстрагироваться, рендерер должен быть отдельный от игры. То есть надо иметь возможность подменить рендерер на DirectX, OpenGL или даже алфавитно-цифровой, и игра должна всё равно работать без переписывания остального кода.
Отсюда возникает требование к наличию второго компонента –
Renderer
Снаружи он даёт интерфейс методов рисования, которые всегда одинаковы, а внутри себя реализует доступ к методам конкретной низкоуровневой подсистемы, и знает, как подготовить данные объекта для этой подсистемы.
Например, рендерер OpenGL трансформировал бы битмапы в текстурированные полигоны, а алфавитно-цифровой рендерер брал бы RGB-значения пикселов битмапов и подбирал для них символы.
Цикл рисования выглядел бы примерно так (это некий обобщённый язык программирования):
И если бы все игровые объекты были, например, битмапами, то проблем бы не было.
Но объекты могут рисоваться по-разному. Я выделяю как минимум следующие типы: битмап, текст, прямоугольник (в общем случае – растеризация полигонов), эффект (процедурная графика типа дыма, взрыва, свечения и т.д.)
Соответственно рендерер должен иметь такие интерфейсные методы:
- drawBitmap(obj)
- drawText(obj)
- drawRect(obj)
- drawEffect(obj)
Тогда цикл рисования изменяется, опять же условно, так:
Честно говоря, особой проблемы в этом не вижу. На рисование каждого объекта добавляется от одного до четырёх if, и в прогнозе мы имеем какие-то десятки или сотни лишних if на кадр. Не так чтоб смертельно для большинства случаев. Учитывая, что даже тупой цикл рисования одного битмапа 256*256 уже содержит 65536 if, дополнительная нагрузка не делает погоды.
И всё же я из принципа стремился получить такое решение, чтобы if были не нужны. Попытки уже делались в игре Apple, но так ничего и не вышло. Там пришлось использовать те же if, только в виде match:
И не то чтобы я не старался, а оно действительно не получилось по-другому. Спустя время это выглядит довольно странно, потому что альтернативные решения существуют. Но вероятно был какой-то подвох, который сразу не видно.
С новыми силами
Я попробую ещё раз пройти этот путь и уже досконально выяснить, в чём этот подвох, и есть ли другие варианты.
Проще всего было бы разделить список объектов по типам. Сначала рисуем все битмапы, потом все тексты, потом все эффекты и т.д. Такие сценарии могут быть, но в общем случае объекты разных типов перемешаны и должны выводиться в произвольном порядке.
Чтобы понять, как надо рисовать объект, надо узнать его тип. А это конечно ведёт к if. Очевидное решение – объект должен рисовать сам себя, потому что ему не надо проверять собственный тип.
Цикл рисования будет выглядеть так:
Здесь каждый объект имеет собственный метод draw(), который и вызывается для рисования. В этот метод передаётся рендерер, чтобы объект мог использовать его для вызова определённого метода.
Например, у объекта типа Bitmap метод draw() будет вызывать renderer.drawBitmap(). У объекта типа Rect метод draw() будет вызывать renderer.drawRect(), и т.д.
Таким образом, if нет и все довольны. Но теперь объекты становятся разных типов. Это не один условный StageObject, а BitmapObject, TextObject, RectObject и т.д. У них всех есть метод draw(), и это могло бы прокатить в JavaScript или PHP, но в Rust не прокатит.
Мы теперь даже не можем иметь список объектов, потому что все они разного типа. Следовательно, надо их как-то обернуть в единый тип.
Сделать это можно с помощью трейтов, чтобы ссылаться не на конкретный тип объекта, а на его признаки (иначе говоря, интерфейс).
Определю трейт Drawable, которым должны обладать объекты:
Теперь описываю типы DrawableBitmap и DrawableRect, которые будут обладать трейтом Drawable:
У битмапа есть атрибут handle – это некий условный указатель на битмап, что сейчас неважно. У прямоугольника есть атрибут типа Rect, то есть собственно прямоугольник, но тоже неважно. Главное, что у разных типов разные атрибуты, которые должны использоваться по-разному, и именно это надо проверить.
Напишу реализацию трейта Drawable для обоих типов объектов:
Метод draw() в каждом случае получает ссылку на рендерер и вызывает у него соответствующий данному объекту метод с соответствующими аргументами.
Теперь напишу конкретную реализацию Renderer.
Атрибут canvas тут чисто условный, для создания видимости использования. Этот рендерер просто выводит сообщения, что рисует. Замечу, что рендерер может быть также оформлен как трейт, чтобы можно было динамически подставлять в draw() разные рендереры, но такой сценарий использования кажется бесполезным. Вряд ли приложение будет использовать более одного рендерера одновременно. Поэтому целевой рендерер фиксируется в исходном коде, и для его замены будет нужна перекомпиляция.
Теперь мы можем создать объект типа DrawableBitmap либо объект типа DrawableRect, но поместить их в один массив нельзя, так как они разных типов (а загвоздка даже не в типах, а в размерах). Вместо самих объектов в массив можно поместить ссылки на них, так как размер ссылки одинаков.
И вот есть первый успех: объекты разного типа в одном списке, их можно брать из списка и рисовать каждый по-своему. Программа выводит на печать:
drawing Bitmap 5
drawing Rect 10x10
Это значит, что корректные методы рендерера вызвались с корректными параметрами.
Можно ли уже радоваться?
Нужно учесть, что ещё может происходить в игре. Помимо отрисовки самих себя, объекты ещё и как-то двигаются.
Попробуем поменять атрибуты объектов, конкретный смысл неважен, главное чтобы что-то изменилось:
drawables[0].handle = 1;
drawables[1].rect.w = 100;
И конечно, это не сработает, так как в массиве имеем только лишь ссылку на признак Drawable, с помощью которой можем вызывать метод draw(), но не знаем, что это за конкретный тип объекта, и какие у него есть атрибуты.
С другой стороны, все экранные объекты обладают рядом одинаковых свойств. Например, это экранные координаты x, y. Мы могли бы эти свойства выделить в некий общий объект, и добавить к этому общему объекту ссылку на Drawable. Попробуем:
Теперь экранный объект это структура StageObject, которая содержит ссылку на трейт Drawable, который знает, как себя рисовать. Напомню, что в таких случаях Rust требует указывать время жизни, и 'a означает: есть структура StageObject с временем жизни 'a, которая содержит ссылку на Drawable, у которой время жизни не меньше чем 'a.
Создадим массив из объектов StageObject:
Теперь массив содержит не ссылки, а сами объекты, так как тип StageObject имеет фиксированный размер. А вот объекты уже содержат ссылки, как раньше. Рисование будет происходить так:
objects[0].drawable.draw(& mut renderer);
Теперь попробуем изменение атрибутов в StageObject:
objects[0].x = 1;
objects[1].y = 100;
Оно, конечно, тоже работает, ведь атрибуты StageObject компилятору известны.
Поведение
С рисованием стало понятно, но игровые объекты должны ещё как-то себя вести. Когда они себя как-то ведут, это обычно отображается на экране. То есть у StageObject должна, к примеру, меняться координата x. Это значит, что поведение объекта как-то должно быть связано со StageObject, чтобы влиять на него. Здесь Rust может преподнести сюрприз.
Для начала, нужно ли расширять непосредственно StageObject какими-то состояниями поведения? Думаю, нет, так как ответственность надо разделять. StageObject нужен исключительно для отображения объекта на сцене. Сами же игровые объекты могут работать вообще без отображения.
Поэтому сделаю новый тип: GameObject. Эти объекты тоже будут храниться в списке.
Сейчас достаточно рассмотреть минимальную структуру GameObject, которая имеет ссылку на StageObject:
Ссылка нужна, чтобы связать каждый GameObject с его отображением StageObject, и она должна быть мутабельной, чтобы можно было изменять атрибуты StageObject для изменения его положения на экране и т.д.
Создадим StageObject, GameObject и свяжем их:
Благодаря мутабельной ссылке логика GameObject может менять параметры в StageObject. Но эта лёгкость обманчива.
Ведь StageObject надо ещё добавить в список объектов сцены. Пока просто создадим массив из одного элемента с объектом sto:
let mut stage_objects: [StageObject; 1] = [ sto ];
В языке C это прошло бы без проблем, но тут возникает ошибка:
Дело в том, что помещая переменную sto в массив, мы передаём владение этому массиву, то есть переменная становится недействительной. Но мы уже заняли (borrow) мутабельную ссылку на неё. Значит, пока существует ссылка, владение передать нельзя.
В массив также можно было поместить ссылку, а не сам объект, тогда владение бы не передалось. Но мутабельную ссылку мы уже получили, поэтому вторую получить нельзя. Это тупик.
Попробуем по-другому. Сначала поместим объект в массив, а затем возьмём ссылку на элемент массива:
Так оно работает, но только один раз. Поместить в массив второй элемент и взять на него ссылку мы уже не можем, потому что ссылка блокирует весь массив.
Очевидно, что попытки как-то обойти ограничения ссылок являются не более чем костылями и не дадут в полной мере реализовать программу.
Зрим в корень
С точки зрения Rust ссылки это зло. Нельзя иметь более одной мутабельной ссылки на данные, а чаще всего нам нужна именно мутабельная, так как нужно что-то менять.
Трудности возникают из-за того, что я создаю целые списки объектов, которые захватывают ссылки на что-то уже при своём создании и дальше просто постоянно хранят их. Получается такая супер-ограниченная структура взаимоотношений объектов, где мы не можем получить ни к чему доступ, потому что всё уже перманентно занято. Она парализует сама себя.
Отсюда вывод – ссылки должны создаваться локально и временно, и сразу же освобождаться.
Например, когда вызывается drawable.draw(), создаётся ссылка на drawable, а после завершения draw() она освобождается. Действительно, она нужна только здесь и сейчас, и её не надо хранить постоянно. Попробуем распространить этот принцип дальше.
Можно ли не хранить ссылку в GameObject, а получать её только тогда, когда она нужна?
Так как StageObject лежат в массиве, попробуем хранить не ссылки на них, а индексы массива.
Теперь, когда нам требуется получить доступ из GameObject к StageObject, мы будем получать его по индексу массива:
И всё работает, и мы даже обошлись даже без временных ссылок. По крайней мере одна проблема решена, но можно ли это решение масштабировать дальше?
Теперь для обработки GameObject нужен доступ ко всему массиву stage_objects, то есть условная процедура обработки всех объектов будет выглядеть так:
Она принимает мутабельную ссылку на массив game_objects и мутабельную ссылку на массив stage_objects. Внутри процедуры всё работает, потому что мы не создаём новые ссылки. После завершения процедуры мутабельные ссылки освобождаются и мы чисты перед законом Rust.
Предположим, нам надо из этой функции вызвать другую функцию, которой тоже нужна будет мутабельная ссылка на массив объектов. Мало ли что. Надо проверить, что будет:
Теперь мы из process_game_objects() вызываем функцию process_test(), передавая туда мутабельную ссылку на массив stage_objects, и это прекрасно работает. Ведь мы не создаём новых ссылок.
Вернёмся к обработке конкретных типов поведения.
Трейты поведения
Предположим, есть два простейших типа поведения: двигаться и ждать. При движении должна меняться координата у StageObject (это уже получилось реализовать), а при ожидании нужно увеличивать счётчик, который является состоянием поведения.
Чтобы обработать поведения игровых объектов, нужно также различать их по игровому типу. Условный код:
Здесь видим полную аналогию со StageObject, и решение может быть аналогичное с использованием трейтов-поведений. Но в отличие от отображения, чаще всего обработка не требует смешанного порядка. Можно спокойно обработать все пули, потом всех монстров и т.д. Так что список объектов можно разделить по типам и каждый тип обработать отдельно с уже известным поведением:
Единственное, что придётся поддерживать несколько списков по типам объектов.
Можно всё-таки попробовать реализовать поведения для единого списка, чтобы посмотреть на возможные подводные камни.
Идти будем тем же путём, как для Drawable: у каждого типа поведения будет трейт Behaviour с интерфейсным методом update().
Метод update() должен получать мутабельную ссылку на GameObject, чтобы поведение могло менять GameObject, но на самом деле это для иллюстрации, что так делать нельзя, что мы позже и увидим.
В GameObject добавляем собственный атрибут x, чтобы было что менять, и также мутабельную ссылку на трейт Behaviour:
Пока опущу код конкретных реализаций трейта Behaviour. Теперь, теоретически, обработка игровых объектов будет происходить так:
Но конечно возникает ужасная красная волнистая линия:
Происходит вот что:
- behaviour.update() требует мутабельную ссылку на behaviour, потому что в сигнатуре метода присутствует &mut self.
- Несмотря на то, что есть мутабельная ссылка на gmo, можно получить мутабельную ссылку на часть gmo, а именно на gmo.behaviour. Это работает, потому что можно брать ссылки на части структуры, которые не пересекаются в памяти.
- В метод behaviour.update() передаётся мутабельная ссылка на behaviour, и мутабельная ссылка на весь объект gmo.
- Это значит, что через переданную ссылку на gmo мы можем получить доступ к behaviour и тогда память у этих двух переданных ссылок окажется пересекающейся. Что недопустимо.
Чем помочь?
Принципиальная проблема в том, что создаются ссылки, которые ссылаются сами на себя. Из gmo берётся behaviour, а в behaviour.update передаётся снова gmo, из которого снова можно взять behaviour, и так можно продолжать до бесконечности. Компилятору такое не нравится, он не может за всем проследить, и потому просто запрещает.
Тогда надо поменять структуру GameObject.
Выделим атрибуты GameObject в отдельную структуру GMOData:
И добавим эту структуру в GameObject в виде атрибута data:
И теперь будем передавать в update() не ссылку на gmo, а ссылку на gmo.data:
Конечно, трейт Behaviour надо соответственно изменить:
И это работает! Теперь из gmo берётся ссылка на behaviour, и другая ссылка на data, и они не пересекаются и не ссылаются обратно на gmo. Порочный круг разорван.
Это далеко не конец истории, но пора закругляться. Дальше буду рассматривать реализацию списка объектов.
Читайте дальше: