В прошлом уроке мы создали статический сцену с игроком и нескольких врагов. Это немного скучно. Настало время добавить наш фон в сцену.
Эффект параллакса
Эффект, который вы найдете в каждой 2D игре за последние 15 лет называется параллакс-эффектом. Короче говоря, идея заключается в том, чтобы перемещать фоновые слои с разной скоростью (т.е. чем дальше слой, тем медленнее он движется). Если все сделано правильно, то создается иллюзия глубины. Это здорово, красиво и просто в использовании.
Давайте реализуем все это в Unity. Добавление оси скроллинга – задача непростая, и нам придется поразмыслить, как написать остальной код игры с учетом этого аспекта. Над этим стоит задуматься, прежде чем начать кодировать :) Мы можем воспользоваться несколькими решениями:
- Игрок и камера двигаются, все остальное неподвижно
- Игрок и камера неподвижны. Все остальное движется
Первый вариант представляет собой достаточно сложную задачу, если у вас режим камеры Perspective
(камера будет отображать объекты в режиме перспективного просмотра). Параллакс очевиден: фоновые элементы отличаются большей глубиной. Поэтому кажется, что они движутся медленнее.
Но в стандартной 2D игре в Unity мы используем ортографическую
камеру (которая будет отображать объекты в ортогональном режиме, т.е. без эффекта глубины). У нас не будет такой величины, как глубина, в принципе.
Немного о камере: помните такое свойство нашей камеры, как "Projection"? В нашей игре оно установлено на Orthographic
. Режим
Perspective
означает, что мы имеем дело с классической 3D камерой с управлением глубиной. Когда мы переключаетмся на ортографическую
камеру, все объекты отображаются с одинаковой глубиной. Это особенно полезно для графического интерфейса или 2D игры.
Чтобы добавить в нашу игру эффект параллакс-скроллинга, нам придется использовать оба эти способа. Итого у нас будет два скроллинга:
- Игрок движется вперед вместе с камерой.
- Фоновые элементы движутся с разной скоростью (в дополнение к движению камеры).
Вы можете спросить: "Почему бы нам просто не установить камеру как дочерний объект объекта игрока?". Действительно, в Unity, если вы установите объект (напр. камера ) в качестве дочернего по отношению к объекту игры, то этот объект будет сохранять свою относительную позицию по отношению к своему родителю. Так что, если камера дочерний объект по отношению к игроку и направлена на него, она будет следовать за ним. Да, можно сказать, что это решение задачи, но оно не будет соответствовать нашему геймплею.
В играх жанра shmup, камера ограничивает передвижения игрока. Если камера перемещается вслед за игроком и по горизонтальной, и по вертикальной оси, игрок свободен в своих передвижениях и может идти, куда захочет. Но в нашем случае игрок ДОЛЖЕН находиться внутри четко обозначенной области.
Мы бы также порекомендовали использовать камеру в качестве самостоятельного объекта в 2D играх. Даже в платформере камера не строго привязана к игроку: она следует за его передвижениями с некоторыми ограничениями. В качестве одного из лучших примеров реализации камеры в платформере можно привести всем известную игру Super Mario World.
Спавн (место появления) врагов
Однако, добавление в игру параллакс-скроллинга приводит к определенным последствиям, особенно это касается врагов. На данном этапе, они просто передвигаются по карте и стреляют с первой секунды игры. Но нам надо, чтобы они ждали и оставались неуязвимыми до начала спавна.
Как построить спавн врагов? Конечно, это в первую очередь зависит от игры. Вы можете создать события, которые запускают спавн врагов, точки спавна, заданные положения и т.д.
Вот что мы сделаем : Мы расположим осьминогов на карте просто перетянув префабы
на нужное место. По умолчанию они статичны и неуязвимы пока не попадут в камеру, которая активирует их.
Что хорошо – так это то, что для настройки врагов можно использовать редактор Unity. Вы прочитали правильно: не прилагая абсолютно никаких усилий вы получаете готовый редактор уровней .
Мы действительно думаем, что вам стоит использовать редактор Unity в качестве редактора уровней, если, конечно, у вас нет недостатка во времени, средствах и собственных редакторах уровней, для которых нужны специальные инструменты.
Слои
Для начала нам нужно определиться с нашими слоями и указать, какие из них будут закольцованы. Закольцованный фон будет повторяться снова и снова на протяжении уровня. Это особенно полезно для таких вещей, как небо. Добавьте новый слой на сцену для фоновых элементов. Вот, что у нас должно получиться:
Слой |
Повторяющееся |
Позиция |
Задний фон с небом |
Да |
(0, 0, 10) |
Задний фон (1-й ряд летающих платформ) |
Нет |
(0, 0, 9) |
Средний фон (2-й ряд летающих платформ) |
Нет |
(0, 0, 5) |
Передний план с игроками и врагами |
Нет |
(0, 0, 0) |
Мы также можем добавить слои впереди игрока. Просто следите за тем, чтобы координаты оси Z
находились между[0, 10]
, иначе вам придется долго настраивать камеру.
Добавляя слои перед игроком, находящимся на переднем плане, следите за тем, чтобы все объекты были четко видны и отличимы на фоне друг друга. Во многих играх эта техника не используется, так как она влияет на четкость графики.
В стандартных пакетах Unity есть кое-какие скрипты для параллакс-скроллинга (взгляните на демо 2D платформера в Asset Store). Вы, конечно, можете воспользоваться ими, но я подумал, что было бы интересно написать его с нуля. Вообще, старайтесь не использовать стандартные пакеты, поскольку они не дают вашему разуму развиваться. Вы ведь не ходите создать бесполезный клон, который никто и не вспомнит?
Простой скроллинг
Мы начнем с легкого: прокрутка фона без цикла. Помните, "MoveScript", который мы использовали ранее? Принцип тот же: скорость и направление, применяемые на протяжении определенного промежутка времени.
Создайте новый скрипт и назовите его "ScrollingScript":
using UnityEngine;
/// Скрипт параллакс-скроллинга, который нужно прописать для слоя
public class ScrollingScript : MonoBehaviour
{
// Скорость прокрутки
public Vector2 speed = new Vector2(2, 2);
// Направление движения
public Vector2 direction = new Vector2(-1, 0);
// Движения должны быть применены к камере
public bool isLinkedToCamera = false;
void Update()
{
// Перемещение
Vector3 movement = new Vector3(
speed.x * direction.x,
speed.y * direction.y,
0);
movement *= Time.deltaTime;
transform.Translate(movement);
// Перемещение камеры
if (isLinkedToCamera)
{
Camera.main.transform.Translate(movement);
}
}
}
Прикрепите скрипт к объектам игры со следующими значениями:
Слой |
Скорость |
Направление |
Связан с камерой |
0 - Задний фон |
(1, 1) |
(-1, 0, 0) |
Нет |
1 - Фоновые элементы |
(1.5, 1.5) |
(-1, 0, 0) |
Нет |
2 - Средний слой |
(2.5, 2.5) |
(-1, 0, 0) |
Нет |
3 - Передний план |
(1, 1) |
(1, 0, 0) |
Да |
А теперь, давайте добавим элементы на сцену:
- Добавьте третий слой после двух предыдущих.
- Добавьте несколько небольших платформ в слое
1 - Фоновые элементы
.
- Добавьте платформы в слое
2 - Средний слой
.
- Добавьте врагов с правой стороны на слое
3 - Передний план
, подальше от камеры.
Результат:
Неплохо! Но мы видим, что враги двигаются и стреляют когда они находятся вне камеры, даже до начала спавна!
Более того, проходя мимо игрока, они не исчезают (уменьшите изображение в режиме "Scene" и посмотрите налево: Poulpies все еще двигаются). Поэкспериментируйте со значениями.
Мы исправим эти проблемы в дальнейшем. Во-первых, мы должны управлять бесконечным фоном (небо).
Бесконечный фон
Для того, чтобы получить бесконечный фон, нам нужно всего лишь проследить за детским объектом слева от бесконечного фона.
Когда этот объект выходить за левый край кадра, мы передвигаем его в правую часть слоя. И так до бесконечности .
Слой, заполненный изображениями, должен полностью покрывать все пространство кадра, чтобы мы не могли рассмотреть, что находится за ним. В данном случае слой неба состоит из трех частей, но это чисто индивидуальное решение.
Найдите правильный баланс между потреблением ресурсов и гибкостью для вашей игры.
В нашем случае смысл состоит в том, чтобы разместить все детские объекты в пределах слоя и установить для них рендерер.
Небольшая заметка о том, как правильно использовать рендерер: Этот метод не работает для видимых объектов (то есть, тех к которым привязаны скрипты). Но вряд ли вам когда-нибудь понадобиться применять его для невидимых объектов.
Мы используем удобный метод, чтобы проверить, видит ли камера рендерер объекта. Мы нашли его в статье the community wiki. Это не класс и не скрипт, это расширение класса С#.
Расширение: Язык C# позволяет расширить класс без использования базового исходного кода класса. Создадим статичный метод, начав с первого параметра, который выглядит так: this Type currentInstance
. В классе Type
теперь доступен новый метод везде, где доступен ваш собственный класс. В рамках метода расширения можно использовать текущий экземпляр класса, применяя метод с помощью параметра currentInstance
вместо этого
.
Скрипт "RendererExtensions"
Создайте новый C# файл "RendererExtensions.cs" и напишите в нем:
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
/// Скрипт параллакс-скроллинга, который должен быть прописан для слоя
public class ScrollingScript : MonoBehaviour
{
// Скорость прокрутки
public Vector2 speed = new Vector2(10, 10);
// Направление движения
public Vector2 direction = new Vector2(-1, 0);
// Движения должны быть применены к камере
public bool isLinkedToCamera = false;
// 1 – Бесконечный фон
public bool isLooping = false;
// 2 – Список детей с рендерером
private List<Transform> backgroundPart;
// 3 - Получаем всех детишек))
void Start()
{
// Только для безконечного фона
if (isLooping)
{
// Задействовать всех детей слоя с рендерером
backgroundPart = new List<Transform>();
for (int i = 0; i < transform.childCount; i++)
{
Transform child = transform.GetChild(i);
// Добавить только видимых детей
if (child.renderer != null)
{
backgroundPart.Add(child);
}
}
// Сортировка по позиции.
// Примечание: получаем детей слева направо.
// Мы должны добавить несколько условий для обработки
// разных направлений прокрутки.
backgroundPart = backgroundPart.OrderBy(
t => t.position.x
).ToList();
}
}
void Update()
{
// Перемещение
Vector3 movement = new Vector3(
speed.x * direction.x,
speed.y * direction.y,
0);
movement *= Time.deltaTime;
transform.Translate(movement);
// Перемещение камеры
if (isLinkedToCamera)
{
Camera.main.transform.Translate(movement);
}
// 4 - Loop
if (isLooping)
{
// Получение первого объекта.
// Список упорядочен слева (позиция по оси X) направо.
Transform firstChild = backgroundPart.FirstOrDefault();
if (firstChild != null)
{
// Проверить, находится ли ребенок (частично) перед камерой.
// Первым делом мы тестируем позицию, т.к. метод IsVisibleFrom
// немного сложнее воплотить в жизнь
if (firstChild.position.x < Camera.main.transform.position.x)
{
// Если ребенок уже слева от камеры,
// мы проверяем, покинул ли он область кадра, чтобы использовать его
// повторно.
if (firstChild.renderer.IsVisibleFrom(Camera.main) == false)
{
// Получить последнюю позицию ребенка.
Transform lastChild = backgroundPart.LastOrDefault();
Vector3 lastPosition = lastChild.transform.position;
Vector3 lastSize = (lastChild.renderer.bounds.max - lastChild.renderer.bounds.min);
// Переместить повторно используемый объект так, чтобы он располагался ПОСЛЕ
// последнего ребенка
// Примечание: Пока работает только для горизонтального скроллинга.
firstChild.position = new Vector3(lastPosition.x + lastSize.x, firstChild.position.y, firstChild.position.z);
// Поставить повторно используемый объект
// в конец списка backgroundPart.
backgroundPart.Remove(firstChild);
backgroundPart.Add(firstChild);
}
}
}
}
}
}
Поясним комментарии с цифрами:
- Нам нужна публичная переменная для включения режима «замыкание» в Инспекторе.
- Там также нужна частная переменная для хранения детских объектов слоя.
- Используя метод
Start()
, мы добавляем в список backgroundPart
детей, у которых есть рендерер. Благодаря небольшому количеству, мы ранжируем их по положению на оси X
и ставим самый левый объект на первое место в массиве.
- При использовании метода
Update()
, если для свойства isLooping
установлено значение true
, мы извлекаем первый детский объект из списка backgroundPart
. Затем проверяем, находится ли он полностью за пределами кадра. Если да – изменяем его положение, помещая его после последнего (самого правого) ребенка. Наконец, мы ставим его в конец списка backgroundPart
.
Действительно, backgroundPart
точно отражает то, что происходит в нашей сцене.
Не забудьте включить свойство "Is Looping" в "ScrollingScript" для 0 - Background
на панели "Inspector". В противном случае (и это очевидно) мы ничего не добьемся.
Нажмите на картинку выше, чтобы увидеть анимацию.
Почему мы не используем методы OnBecameVisible()
и OnBecameInvisible()
? Потому что здесь они не работают.
Смысл этих методов заключается в исполнении фрагмента кода при появлении объекта на экране (или наоборот). Они работают как методы Start()
или Stop()
(если вы решили использовать один из них, просто добавьте нужный метод в MonoBehaviour
, Unity применит его).
Проблема в том, что эти методы также применяются при использовании режима "Scene" в редакторе Unity. А значит, поведение объектов в редакторе Unity и самой игре (независимо от платформы) будет отличаться. Это опасно и абсурдно. Мы всячески рекомендуем избегать этих методов.
Бонус: Улучшение написанных скриптов
Давайте обновим наши предыдущие скрипты.
Враг, версия 2 со спавном
Мы ранее говорили, что враги должны быть отключены, пока они не видны камерой.
Они также должны использоваться повторно, полностью пропав из поля зрения.
Мы должны обновить "EnemyScript", сделав вот что:
- Отключить движения, коллайдер и авто-огонь (при инициализации).
- Проверить, попал ли рендерер в камеру.
- Запустить самоактивацию.
- Уничтожить объект игры, когда он находится вне камеры.
(Цифры указывают на комментарии в коде)
using UnityEngine;
/// Общенное поведение врага
public class EnemyScript : MonoBehaviour
{
private bool hasSpawn;
private MoveScript moveScript;
private WeaponScript[] weapons;
void Awake()
{
// Получить оружие только один раз
weapons = GetComponentsInChildren<WeaponScript>();
// Отключить скрипты, чтобы деактивировать объекты при отсутствии спавна
moveScript = GetComponent<MoveScript>();
}
// 1 - Отключить все
void Start()
{
hasSpawn = false;
// Отключить
// -- коллайдеры
collider2D.enabled = false;
// -- Перемещение
moveScript.enabled = false;
// -- стрельбу
foreach (WeaponScript weapon in weapons)
{
weapon.enabled = false;
}
}
void Update()
{
// 2 - Проверить, начался ли спавн врагов.
if (hasSpawn == false)
{
if (renderer.IsVisibleFrom(Camera.main))
{
Spawn();
}
}
else
{
// автоматическая стрельба
foreach (WeaponScript weapon in weapons)
{
if (weapon != null && weapon.enabled && weapon.CanAttack)
{
weapon.Attack(true);
}
}
// 4 – Выход за рамки камеры? Уничтожить игровой объект.
if (renderer.IsVisibleFrom(Camera.main) == false)
{
Destroy(gameObject);
}
}
}
// 3 - Самоактивация.
private void Spawn()
{
hasSpawn = true;
// Включить все
// -- Коллайдеры
collider2D.enabled = true;
// -- Перемещение
moveScript.enabled = true;
// -- Стрельбу
foreach (WeaponScript weapon in weapons)
{
weapon.enabled = true;
}
}
}
Давайте теперь запустим нашу игру. Хммм... ошибочка.
Отключение "MoveScript" привело к нежелательному результату: игрок никогда не дойдет до врагов, так как все они двигаются вместе со скроллингом слоя 3 - Foreground
:
Помните: мы добавили "ScrollingScript" к этому слою, чтобы перемещатьть камеру вместе с игроком.
Но есть простое решение: переместить "ScrollingScript" со слоя 3 - Foreground
на игрока!
В конце концов, почему бы и нет? Единственный объект, который двигается в этом слое – это сам игрок, и скрипт не прописан специально для определенного типа объекта.
Нажмите кнопку "Play" и убедитесь, что все работает.
- Враги отключены до начала спавна (то есть, пока камера не достигнет точки, в которой они находятся).
- Затем они исчезают, когда они находятся за пределами камеры.
Вы наверняка заметили, что игрок еще не ограничен рамками камеры. Нажмите "Play", затем кнопку со стрелочкой влево – и он покинет кадр.
Мы должны это исправить. Откройте "PlayerScript", и добавить в конце метод "Update()":
void Update()
{
// ...
// 6 – Убедиться, что игрок не выходит за рамки кадра
var dist = (transform.position - Camera.main.transform.position).z;
var leftBorder = Camera.main.ViewportToWorldPoint(
new Vector3(0, 0, dist)
).x;
var rightBorder = Camera.main.ViewportToWorldPoint(
new Vector3(1, 0, dist)
).x;
var topBorder = Camera.main.ViewportToWorldPoint(
new Vector3(0, 0, dist)
).y;
var bottomBorder = Camera.main.ViewportToWorldPoint(
new Vector3(0, 1, dist)
).y;
transform.position = new Vector3(
Mathf.Clamp(transform.position.x, leftBorder, rightBorder),
Mathf.Clamp(transform.position.y, topBorder, bottomBorder),
transform.position.z
);
// Вот и весь метод Update
}
Как видите, ничего сложного.
Мы определяем границы камеры и делаем так, чтобы положение игрока (центр спрайта ) не выходил за границы кадра.
Что дальше?
Мы только что узнали, как добавить механизм прокрутки для нашей игры, а также эффект параллакса для фоновых слоев. Тем не менее, текущий код работает только для прокрутки справа налево. Тем не менее, вы уже в состоянии сами увеличить ее и заставить работать на всех направлениях (если у вас все же не получается, держите готовый код).
Но чтобы сделать нашу игру играбельной, нам все же понадобится внести некоторые изменения. Например:
- Снижение размеров спрайта.
- Настройка скоростей.
- Добавление новых врагов.
В следующей главе мы еще немного поработаем над нашей игрой, поэкспериментируя с частицами!