Разобравшись с библиотекой SDL и памятью в Rust, можно приступать к проектированию игры.
Предыдущие части: Итоги про память, Что там с памятью, Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину
В чём заключается игра? Внизу экрана бегает влево-вправо человечек с сачком, а сверху падают яблоки.
Яблоки и человечек имеют определенный размер и привязаны к сетке экрана таким образом, что могут занимать только фиксированные позиции.
При этом яблоко либо падает мимо человечка, либо попадает в одну из точек: корзину или голову. Как видно, они тоже привязаны к сетке и точно совпадают с положением яблока по горизонтали:
Представление данных
В каждый момент времени может падать только одно яблоко. Следовательно, всё состояние игры описывается только двумя объектами: яблоко и человечек.
Чтобы закодировать яблоко, достаточно знать его координаты (x, y). То же самое и насчёт человечка. Значит, данные о яблоке и человечке можно представить следующим образом:
let mut apple_x = 0;
let mut apple_y = 0;
let mut player_x = 0;
let mut player_y = 500; // где-то внизу экрана
Обратите внимание, что используется объявление let mut, которое делает переменные изменяемыми (мутабельными). Ведь они будут изменяться.
А игровой цикл будет представлять из себя последовательное падение яблока и проверку на столкновения:
Давайте разберём. Переменная playing имеет логический (булевый) тип и принимает значения false/true. Организован главный цикл игры: while playing, который будет работать, пока значение переменной playing не станет равно false.
В цикле яблоко падает вниз. То есть координата apple_y увеличивается на 1.
Как появляются новые яблоки? Можно видеть такое условие: если apple_y равно 0, то apple_x = generate(). В самом начале apple_y равно 0, то есть яблоко находится в самом верху экрана.
При выполнении такого условия мы вызываем функцию с условным именем generate(), которая возвращает случайное число – горизонтальную координату яблока. Это число мы присваиваем переменной apple_x, и яблоко теперь находится по этой координате.
Так как координата apple_y увеличивается на каждом шаге цикла, больше она не будет равна нулю и поэтому условие на генерацию новой координаты в дальнейшем срабатывать не будет. Яблоко по горизонтали будет оставаться там, где есть, просто смещаясь вниз.
Но как только оно упадёт ниже, чем голова игрока, нужно удалить его и сделать новое яблоко сверху. Для этого служит условие:
if apple_y > player_y
При его выполнении мы присвоим apple_y = 0, то есть на следующем шаге цикла снова выполнится условие if apple_y == 0, вызовется generate() и координате apple_x будет присвоено новое случайное число. Таким образом, яблоко переместится наверх и снова начнёт падать в случайном месте.
Для нас это будет выглядеть так, как будто старое яблоко исчезло, а сверху появилось новое. На самом деле это то же самое яблоко, просто мы переставили его.
Ну и наконец, если яблоко упало на голову игроку, это тоже надо проверить. Здесь надо сравнить x-координату яблока и x-координату ирока:
if apple_x == player_x
В случае выполнения условия мы предпримем какие-то действия. В оригинальной игре человечек "умирает" и игра заканчивается (т.е. переменная playing, которая поддерживает цикл, становится равна false).
Конечно, я опустил множество вещей: рисование графики, привязку к сетке координат, управление человечком, проверку на попадание в голову или в корзину и т.д., но по сути алгоритм игры уже готов.
Мне, однако, не нравится то, что там всё "прибито гвоздями". Условия завязаны на специфические значения координат и предполагается, что яблоко будет только одно. А что, если я захочу сделать несколько падающих яблок? А что, если падать будет не только яблоки, но и бомбы?
Короче говоря, требуется сделать не конкретную игру (потому что это довольно просто и скучно), а некую более универсальную и расширяемую платформу.
Например, если взять игру Galaga:
То легко представить, что наш космический корабль это человечек, а нападающие враги – это падающие яблоки.
При наличии расширяемого движка мы могли бы легко превратить игру Apple в игру Galaga, а также в кучу других игр.
Поэтому я хочу применить отстранённый подход к проектированию, то есть не считать никакие объекты яблоками или человечками, и не приписывать им характерных свойств. Всё должно быть максимально абстрактно.
В Rust нет классов, но я буду использовать слово "класс" как синоним структур и типов, потому что так удобней.
Для начала можно представить, что в игре присутствует некое множество взаимодействующих объектов. Логично, что все они должны иметь какие-то общие свойства. Например, яблоко и человечек имеют координаты (x, y).
Поэтому их можно описать одним классом GameObject, который обладает общими для всех игровых объектов качествами: в данном случае это координаты x, y, а также можно добавить скорость движения по каждой координате: dx, dy.
Далее, так как я отстраняюсь от природы объектов, то не знаю, какую роль выполняет каждый из них или как он себя ведёт. Поэтому каждый GameObject должен иметь метод update(), который заведует тем, как изменяется состояние этого конкретного объекта. Например, для яблока метод update() будет заключаться в том, чтобы прибавить 1 (а точнее, dy) к координате y.
Для человечка update() будет менять координату x в зависимости от того, какую клавишу нажал игрок.
update() – не волшебный метод, и в любом случае надо будет писать конкретную реализацию и для яблока, и для человечка. Но суть в том, что такую реализацию можно написать изолированно, не вмешиваясь в структуру игры.
Далее можно заметить, что важные события в видеоиграх происходят, когда два или более объектов пересекаются друг с другом. На этом завязано практически всё. И прыжки по платформам, и стрельба, и тем более тыканье мышкой по кнопкам.
Определять пересечения объектов тоже можно по-разному, потому что они могут иметь разные размеры, форму, траекторию движения и т.д.
Поэтому я добавлю класс Collider, который будет определять столкновение между двумя объектами типа GameObject.
Столкновения разных объектов могут приводить к самым разным исходам, как хорошим, так и плохим. Чтобы решить, что делать после столкновения, я сделаю класс Solver.
Итак, игровой движок будет основан на трёх базовых классах: GameObject, Collider и Solver. Также можно добавить класс Graphics, чтобы инкапсулировать все графические операции, класс Sound для звука, и класс Input для управления. А также класс Game, который управляет всей игрой и хранит её данные и настройки.
Погрузиться в эти ненужные усложнения можно будет в следующем выпуске.
Читайте дальше: Модули