Какое-то время назад мне давали тестовое задание – сделать казуальную игру на Юнити. Игру я сделал, но она не пригодилась, поэтому тут я буду про неё рассказывать и показывать.
Задание
В игре есть 5 рядов блоков разного цвета и длины.
В первом ряду блоки движутся слева направо, в следующем справа налево, потом опять слева направо и т.д.
Игрок это белый кубик. Он должен перескакивать с блока на блок, стараясь не упасть и не уехать за край экрана.
По сути это такая же игровая механика, как во Frogling.
Проекция камеры изометрическая:
В начале игры игрок стоит на блоке в первом ряду, который не движется. После прыжка игрок попадает на второй ряд, а после ещё одного прыжка ряды начинают двигаться ему навстречу. То есть после первого прыжка игрок всегда находится во втором ряду, а сзади него всегда находится один ряд, с которого он упрыгал.
Когда игрок спрыгивает со второго ряда, все ряды сдвигаются ему настречу. Самый задний ряд пропадает, а впереди добавляется новый ряд. Я про это пишу подробно, так как уже на этом этапе есть некоторая сложность. Неподвижный блок есть только в начале игры. Игрок стоит в первом ряду только в начале игры. Ряды не движутся навстречу игроку при первом прыжке. Все эти условия нужно соблюдать.
Вторая сложность это требования к физике. Кубик при неудачном прыжке должен стукаться о другие блоки, кувыркаться и падать, то есть нужно использовать физический движок. Но если просто включить физику и пустить всё на самотёк, тогда кубик после прыжка будет вставать на блок немного неровно. А мне нужна абсолютно чёткая позиция.
Проектирование
Основные объекты игры это конечно блоки. Чтобы они присутствовали на экране и обрабатывались физикой, они должны быть классом GameObject.
Встал выбор: или наследовать класс блока от GameObject, или создать абсолютно независимый класс, в который я добавлю GameObject как атрибут для взаимодействия с движком Unity. Я выбрал второй способ, и это значит, что я использовал композицию вместо наследования.
Мне это удобно тем, что я могу писать игровую логику совершенно отвлечённо от Unity, и использовать GameObject только для взаимодействия с движком Unity.
Первое, что было нужно, это задать координаты и размеры блока. Но так как все блоки отличаются только длиной, это значит, что для размеров надо хранить только длину. Остальные размеры это просто константа и их хранить не надо. Далее, блоки находятся в разных местах пространства, но также каждый блок находится внутри своего ряда. А там у него есть только одна координата – расстояние от начала. Так что мне требуются лишь два атрибута для хранения положения и размера блока: offset и width. Ну и конечно атрибут для хранения связанного с этим блоком GameObject.
Естественно, следующий объект это ряд. У него также есть координаты, но все ряды одинаково ориентированы и лежат в одной плоскости, так что здесь тоже достаточно одного атрибута: координаты Z, которая отвечает за дистанцию "вглубь" экрана.
Ряд занимается следующими вопросами:
- он имеет некоторое количество блоков
- он двигает эти блоки
- когда блок в начале ряда уезжает за пределы экрана, он удаляется, и в конце ряда должен появиться новый блок.
Хранение и движение блоков
Каждый ряд имеет массив своих блоков, и при движении нужно всего лишь пройтись по всему массиву и каждый блок сдвинуть на какое-то расстояние, то есть изменить его offset. Скорость сдвига назовём speed, и это будет атрибут ряда, так что у блока будет незамысловатое действие
block.offset += row.speed
Далее, если блок вышел за пределы экрана... И тут у нас есть первая проблема.
Ряд может двигаться либо влево, либо вправо. Если он двигается влево, то за пределы экрана выходит первый слева блок, и его координата, условно говоря, становится меньше нуля.
Если же ряд движется вправо, то за пределы экрана выходит первый справа блок, и его координата, условно говоря, становится больше ширины экрана.
То есть для разного направления движения нужно проверять разные блоки и разные условия. Задача, конечно, решается в лоб путём вставки многочисленных if-ов в код. Но хочется более изящного решения.
Можно сделать так: во всех рядах первый блок будет крайний слева, но тогда, если ряд движется вправо, то рисовать его надо задом наперёд.
Если есть подобная сложность, её нельзя упростить. Убирая её в одном месте, мы просто переносим её в другое.
Этот вопрос я пока отложу, чтобы не отвлекаться, и будем считать, что ряд движется влево, и за пределы экрана выходит крайний слева блок, то есть первый в массиве.
Когда сработало условие, что блок вышел за пределы экрана, мы удаляем его из массива, например, делая что-то вроде array.shift() (кстати, в До-диезе я не нашёл внятной функции для этого, все примеры делаются через какие-то дикие конструкции с разрезанием массивов, страшно смотреть).
И одновременно добавляем новый блок в конец массива, используя что-то вроде array.push().
Но мне это всё не понадобилось, так как я с самого начала пошёл на, возможно ненужные, усложнения.
Я подумал, что блоки в течение игры постоянно исчезают и появляются. А значит, это создаёт нагрузку на менеджер памяти и сборщик мусора. К тому же нужно постоянно инициализировать новые объекты.
Повторюсь, что возможно это не является проблемой, но я решил сделать правильно сразу.
В каждом ряду есть массив фиксированной длины, достаточной для того, чтобы вместить необходимое количество блоков от края до края экрана. Например, это может быть максимум 20 блоков.
У ряда есть указатели head и tail, которые показывают на начальный и конечный блок в массиве. Рассмотрим это на примере одного блока:
В массиве длиной 20 элементов первый элемент (с индексом 0) занят блоком. Указатель head соответственно равен 0, а указатель tail тоже 0.
Все рабочие блоки ряда находятся в массиве между head и tail включительно, то есть в данном случае это ровно один блок. Остаток массива просто игнорируется.
Теперь я добавляю в массив новый блок. Я не изменяю длину массива, не вставляю в него ничего, а просто беру элемент, следующий за tail. У него индекс 1. В этот элемент я помещаю новый блок, и сдвигаю tail на 1.
Теперь head = 0, а tail = 1, и между ними находятся 2 блока. Так я могу добавить ещё, скажем, 10 блоков, и tail станет равен 11.
Теперь, допустим, блок с индексом 0 ушёл за пределы экрана. Я не удаляю его из массива, вообще ничего не трогаю, а просто смещаю head на 1.
Теперь head = 1, и активные блоки ряда начинаются с 1, а блок 0 выбыл.
Таким образом, при удалении блока просто сдвигается head, а при добавлении сдвигается tail.
Использованные блоки остаются в массиве. Они никому не мешают, так как активность происходит только между head и tail.
Я получил кольцевой буфер. Когда tail дойдёт до конца массива, он перейдёт на начало. То же самое будет и с head.
Далее, когда tail сдвигается и нужно записывать в массив новый блок, мы можем на этом месте найти либо null (ещё ничего никогда не вставлялось), либо какой-то из старых блоков.
Если там null, то создаётся новый объект блока и записывается в массив. Если же там старый блок, то я не создаю новый объект, а просто заново инициализирую старый.
Инициализация заключается в том, чтобы назначить блоку случайный цвет и размер.
За это отвечает метод блока Reshape(), но чтобы рассмотреть его подробнее, мы должны сначала уточнить структуру данных блока.
Читайте дальше: