В этом выпуске я попытаюсь реализовать паттерн MVC средствами Rust.
Предыдущие части: Поддержка SDL2, Полируем ржавчину
Во-первых, зачем мне MVC? Apple – очень простая игра, которая не требует никакой сложной архитектуры.
Даже самая простая игра состоит из нескольких интерактивных блоков. Первый это заставка, где может быть какая-то анимация или играть музыка. В Apple это есть:
Далее, в игре может быть меню с настройками или выбором уровня сложности. В Apple это тоже есть:
Затем идёт сама игра:
И наконец, должен быть экран окончания игры, экран таблицы рекордов, экран паузы и т.д. Всего этого в Apple нет, она после запуска работает бесконечно, но в ремейке я буду это всё делать.
В чём проблема со всеми этими экранами?
В каждом из них пользователь взаимодействует с игрой, но по-разному. Например, заставка ждёт нажатия любой клавиши. Меню ждёт нажатия клавиш "вверх", "вниз", "вправо", "влево", "пробел" и т.п. Сама игра тоже ждёт нажатий аналогичных клавиш.
В каждом блоке могут работать одни и те же клавиши, но назначение у них будет разное.
Если делать игру влоб, то сначала мы напишем код заставки. Там мы организуем цикл ожидания события – нажатия клавиши. Когда будет нажата нужная нам клавиша, мы пойдём дальше, в код для меню.
В коде для меню мы снова организуем цикл ожидания и снова будем ждать нажатия клавиши. На этот раз нажатия клавиш мы будем обрабатывать по-другому, то есть выбирать уровень сложности и т.п.
Затем мы перейдём в код для игрового процесса. Там у нас тоже будет цикл с обработкой клавиш, и они будут опять обрабатываться по-другому.
Диаграмма работы нашей программы получается такая:
Код опроса дублируется столько раз, сколько у нас разных активностей. И это ещё не всё. Так как игру можно закрыть в любой момент, значит нужно обрабатывать и событие выхода. А в самых тяжёлых случаях нужно реагировать и на изменение размера, и на свёртывание-развёртывание окна и т.п. А значит, эту обработку нужно добавить в каждый цикл опроса.
Теперь представьте, что находясь в игре, вы нажали на "настройки". То есть, находясь внутри цикла опроса игры, нужно теперь организовать цикл опроса для режима настроек?
Всё это быстро выходит из-под контроля.
Главный цикл
Общепринятой практикой стало делать т.н. главный, или основной цикл. Как следует из названия, он один на всю игру. Отпадает проблема дублирования кода и обработки глобальных событий вроде выхода из программы.
Но появляется другая проблема.
Так как цикл общий, то обработка, скажем, клавиши "вверх" вызывает вопросы. Нажата клавиша "вверх", чтобы выбрать другой пункт меню? Или чтобы герой подпрыгнул? Мы должны знать, в каком режиме в данный момент работает игра. И поэтому появляются такие условия:
Наличие таких условий быстро перегружает тело цикла, и хотя с точки зрения программы всё в порядке, он становится очень плохо читаемым.
Контроллер
Вышеописанные причины приводят нас к концепции контроллера. Это последний компонент MVC (Model - View - Controller), и может применяться как частичный паттерн без всего остального.
Контроллер – обособленный кусок кода, который занимается обработкой событий или запросов. Теперь главный цикл будет выглядеть так:
Обработка глобальных событий (типа закрытия окна) остаётся в главном цикле, и поэтому будет работать всегда и везде. А взаимодействием с пользователем теперь занимается отдельный объект controller, у которого мы вызываем метод run().
Тело главного цикла стало компактным и красивым.
Но постойте, контроллер же всё равно должен знать, в каком режиме находится игра? И значит, в нём тоже должны быть написаны условия для разных режимов?
Да. Если у нас один контроллер, то это так. Но во-первых, все эти условия будут уже вынесены из тела главного цикла. Во-вторых, мы можем сделать несколько контроллеров, где каждый занимается только своим режимом. То есть для режима заставки мы напишем класс TitleController, для меню MenuController, для игры – GameController. У каждого из них будет метод run().
Управление в главном цикле теперь будет организовано просто. Если мы присвоим переменной controller объект класса TitleController, то controller.run() будет обрабатывать заставку. Когда придёт время перемещаться в меню, мы присвоим переменной controller объект класса MenuController, и теперь controller.run() будет обрабатывать меню, и т.д. Переключение логики обработки осуществляется простым переназначением контроллера.
Дополнительно мы получаем инкапсуляцию кода в виде отдельных классов, каждый из которых можно дорабатывать отдельно, не затрагивая другие части программы.
Классы? Какие классы?
Всё хорошо, но в Rust нет классов. Что делать?
Суть класса в том, чтобы назвать некую схему данных каким-то именем. Чтобы при создании нового объекта мы не описывали каждый раз, какие у него должны быть свойства, а просто говорили – создай объект вот такого класса. И тогда свойства объекта порождаются из описания класса.
На базовом уровне это умеют делать структуры, которые есть в C и в Rust. Объявляя некую структуру, мы по сути получаем имя класса, с помощью которого можем создавать объекты именно с такой структурой:
Скажем, я описал на Rust структуру с именем Controller, у которой есть два свойства строкового типа: id и key_code. Но это всего лишь пример, так как главное, что меня интересует – это метод run(). Метод – он как свойство, только он функция и его можно вызвать.
Структуры на C не дают возможность объявлять свойства как методы. Вместо этого в свойство можно поместить указатель на функцию и вызывать её по указателю.
К счастью, в Rust есть возможность добавить метод в структуру. Для этого используется несколько неуклюжий синтаксис impl:
Почему он неуклюжий? Вместо того, чтобы описать метод внутри самой структуры (как это делается в классах), используется отдельный блок impl (implementation, то есть реализация), в котором указываем, для какой структуры этот блок пишется (Controller). Внутри него мы можем написать необходимые методы.
Каждый метод оформляется как fn, то есть функция, в которую передаётся ссылка (&) на параметр self, то есть это аналог self в Python и PERL.
Отмечу, что я не заостряюсь тут на ООП-парадигмах, так как им посвящён отдельный цикл материалов. Здесь предполагается, что мы просто повторяем их и адаптируем к Rust.
Ну вот, я сделал минимальный контроллер и использовал его:
И вот так он работает:
Да, здесь сразу возникает много вопросов: как инициализируется структура при создании, и что значат все эти непонятные штуки. Этому будет посвящён следующий выпуск.
Читайте дальше: Колхозим интерфейсы