Привет!
Под катом вас ждёт ещё одна статья, которая расскажет о том, как я поставил себе цель запрограммировать игру, основываясь на переводе статьи на Хабр под названием Паттерны дизайна уровней для 2D-игр.
Статья имеет много текста (как обычного, так и исходного) и много картинок.
Прежде, чем я начну свою первую статью, давайте познакомимся. Моё имя Денис. Я работаю системным администратором с общим стажем в 9 лет. Не мне вам говорить, что системный администратор — это вид ИТшников, которые один раз старательно деплоят, а потом созерцают мерканье различных символов на мониторе. Со временем, я пришёл к выводу, что пора расширять границы знаний и переключаться на программирование. Не в даваясь в подробности, я пытался сделать любые проекты на C++ и Python. Но за год изучения, я пришёл к выводу, что программировать прикладной и системный софт — не моё. По разным причинам.
Поразмыслив глубже, я задал себе вопрос: что я действительно люблю делать за вычислительной техникой разного формата? Мои вопрос самому себе отбросил меня далеко в самое детство, а именно в счастливые часы проведённые за PS1, PS2, Railroad Tycoon 3 for PC…, Ну, вы поняли. Видео игры!
По количеству разнообразных учебных материалов, выбор пал на Unity (не изобретать же велосипед?). После месяца чтения и просмотра различных материалов, а решил выпустить в плей маркет первой очень простую детскую игру. Чтобы перебороть страх так сказать. Ведь выпускать приложения в плей маркет — это не страшно, верно?
Через пару месяцев я выпустил платформер уже более сложный. Затем был перерыв (ведь работу надо работать, всё-таки).
Около двух недель назад я увидел перевод статьи на хабр под названием Паттерны дизайна уровней для 2D-игр (https://habr.com/ru/post/456152/) и подумал про себя — а почему нет? В статье есть простая и наглядная таблица со списком того, что должно быть в игре, чтобы она была интересной. Таблицу я любезно скопировал себе в OneNote и пометил каждый паттерн тегом Дела (который можно помечать выполненными).
Что я хочу получить по итогу? Вашу критику. Как я люблю себе говорить — хочешь научиться плавать, ныряй с головой. Если вы считаете, что я сделал что-то хорошо — напишите мне об этом в комментарии. Если вы считаете, что я сделал что-то плохо — пишите об этом вдвойне.
Я начну свой лонгрид по программированию ещё одного платформера.
Аватар
Сущность, которой управляют игроки внутри игры. Например, Марио и Луиджи в Super Mario Bros (Nintendo, 1985 год).
Есть несколько подзадач, которые необходимо реализовать, чтобы придать герою жизнь. А именно:
• Анимировать Героя (далее Лукас)
• Научить его ходить и прыгать
• У героя должны быть потребности
• Герой может атаковать в случае опасности
• Герой может быть убит или может погибнуть
Для реализации анимации, нам потребуется преобразовать одиночный спрайт в мульти спрайт. Делается это невероятно просто. Складываем спрайт в папку проекта и находим его в проводнике редактора Unity. Затем, щёлкнув мышкой по спрайту, в окне инспектора меняет значение свойства SptiteMode с Single на Multiple.
Нажимаем Apply, затем SpriteEditor.
Внутри окна Sprite Editor необходимо выделить мышкой каждый кадр будущей анимации как показано на рисунке ниже.
Сделаем данную операцию для всех остальных анимации. Далее, необходимо будет настроить состояния анимации и их переключения. Это делается в два действия. Первое действие, программный код. Создаём скрипт и подключаем его к пока ещё пустому игровому объекту. В моём случае, название скрипта HeroScript.cs.
Сперва, описываем поля, которые будут хранить в себе информацию о визуальной и физической составляющей Лукаса:
private Animator animator; //В данном поле содержится информация о анимациях.
private Rigidbody2D rb2d; //rb хранит в себе физику
Далее, поля, которые будут отвечать перемещение персонажа:
/* Определяем поля, относящийся к перемещению героя */
Vector3 localScale;
bool facingRight = true;
[SerializeField]
private float dirX, dirY; // Направление движения героя
[Range(1f, 20f)]
[SerializeField]
private float moveSpeed; // Скорость движения героя
private SpriteRenderer sprite; // хранит компонент SpriteRenderer
/* Конец определения переменных, относящийся к перемещению героя */
Начало положено, отлично. Далее, будет описано перечисление и написано свойство, которое будет отвечать за переключение состояния анимации. Данное перечисление необходимо писать вне класса:
/*
* Данное перечисление отвечает за переключение анимации.
* Анимации будут переключаться по нажатием по кнопкам управления
* или когда персонаж умер.
*/
public enum CharState
{
idle, // константа хранит 0
Run, // константа хранит 1
Walk, // константа хранит 2
Die // константа хранит 3
}
Реализуем свойство, которое будет получать и устанавливать новое состояние анимации:
public CharState State
{
get
{// Возвращаем состояние типа CharState взятое из компонента animator которое было преобразовано в тип данных int
return (CharState)animator.GetInteger("State");
}
set
{ // Записываем в компонент animator с помощью типа данных int используя имя параметра State в которое хотим записать и его значение, которое явно нужно преобраовать в int.
animator.SetInteger("State", (int)value);
}
}
С программной частью закончено. Теперь у нас есть перечисление и свойство, которое будет связано с переключением анимации. Далее, второй шаг. Нужно в редакторе Unity связать состояние анимации и и указать, при каких значениях int их нужно менять.
Для этого, необходимо связать созданные ранее мульти спрайты с пустым игровым объектом. Всё, что вам нужно, это выделить в проводнике Unity кадры и перетащить их на пустой игровой объект, на который мы ранее закрепили скрипт.
Проделайте так с каждой следующей анимацией. Так же, в проводнике с анимациями вы обнаружите появление объекта со изображением блок-схемы и кнопкой Play. Щёлкнув по ней дважды, вы откроете вкладку Animator. Внутри вы увидите несколько блоков с анимациями и изначально, связанны только состояния Entry и первый набор анимации, который были подключены. AnyState и другие анимации будут отображены в виде обычных серых квадратов. Для того, чтобы связать всё, нужно щёлкнуть по состоянию AnyState и выбрать одно единственное выпадающее меню Make Transaction и связать её с серым блоком. Эту операцию необходимо проделать для каждого состояния. В итоге, должно получиться примерно то, что вы видите на скриншоте ниже.
Далее необходимо явно указать, чему именно должна быть равна State, чтобы запустить необходимую анимацию. Обратите внимание на скриншот, а именно левая её часть. Вкладка Parameters. В ней создана переменная типа int State. Далее, обратите внимание на правую часть. Прежде всего, с каждой транзакции необходимо снять галочку Can Transaction To Self. Данная операция избавит вас от странных и иногда совсем не понятных переходов анимации в саму себя и раздел Conditions, где мы указали, что эта транзакция присвоит значение 3 переменной State. После чего Unity будет знать, какую анимацию запустить.
Для анимированного перемещения персонажа всё сделано. Идём дальше.
Следующий шаг, научить Лукаса перемещаться по сцене. Это целиком и полностью программирование. Для перемещения персонажа по сцене, потребуются кнопки, нажимая на которые Лукас будет идти назад и вперёд. Для этого, во вкладке Assets Store нам потребуется импортировать Standart Assets, но не вся его часть, только некоторые дополнительные компоненты, а именно:
• CrossPlatformInput
• Editor
• Environment
После импорта Ассета, главное окно Unity должно модифицироваться и появиться одна дополнительная вкладка Mobile Input. Активируем его.
Создадим на сцене новые UI елементы — кнопки управления. Создадим 4 кнопки по каждому направлению. Вверх, вниз, вперёд и назад. В компоненте Image назначим кнопкам изображение, которое будет соответствовать изображению, означающего возможность перемещения. Должно получиться примерно как на скриншоте ниже:
К каждой кнопке добавляем компонент AxisTouchButton. Данный скрипт имеет всего 4 поля. Поле axisName означает, на какое имя при вызове откликаться. Поле axisValue отвечает за то, в каком направлении будет перемещаться Лукас. Поле responseSpeed отвечает за то, с какой скоростью Лукас будет развивать свою скорость. Поле returnToCentreSpeed отвечает за то, с какой скоростью кнопка вернётся к центру. Для кнопки Вперёд оставим всё как есть. Для кнопки назад изменим значение axisValue на -1, чтобы Лукас двигался назад. Для кнопок Вверх и Вниз изменим axisName на Vertical. Для кнопки Вверх axisValue ставим значение 1, для Вниз -1.
Далее, модифицируем HeroScript.cs. Добавляем в директиву using пространство имён
using UnityStandardAssets.CrossPlatformInput; // Подключение управления с дисплея.
Добавляем новые поля для правильного перемещения по сцене:
/* Определяем переменные, относящийся к перемещению героя */
Vector3 localScale; // Масштаб Лукаса
bool facingRight = true;
[SerializeField]
private float dirX, dirY; // Направление движения героя
[Range(1f, 20f)]
[SerializeField]
private float moveSpeed; // Скорость движения героя
private SpriteRenderer sprite; // хранит компонент SpriteRenderer
/* Конец определения переменных, относящийся к перемещению героя */
В стандартный метод Start добавляем следующий код:
void Start()
{
localScale = transform.localScale;
animator = GetComponent<Animator>(); // Подключаем анимацию во время выполнения.
sprite = GetComponent<SpriteRenderer>(); // Подключаем SpriteRenderer
rb = GetComponent<Rigidbody2D>();
State = CharState.idle;
}
Создаём метод, который быдет отвечать за перемещение героя:
public void MoveHero()
{
dirX = CrossPlatformInputManager.GetAxis ("Horizontal") * moveSpeed * Time.deltaTime;
dirY = CrossPlatformInputManager.GetAxis ("Vertical") * moveSpeed * Time.deltaTime;
transform.position = new Vector2 (transform.position.x + dirX, transform.position.y + dirY);
}
Как видите, тут всё просто. Поля dirX и DirY записывают в себя информацию о том, какое было направление Оси (Horizontla и Vertical) умноженную на скорость (которую нужно будет указать в редакторе) и умноженную на время прохождения с последнего кадра.
transform.position записывает новую позицию в компонент Transform нашего игрового объекта.
С практической стороны вопроса, можно запустить сцену и увидеть -как Лукас падает в бездну, поскольку под ним нет никаких объектов, способных предотвратить это- как герой перемешается по сцене. Но, Лукас всегда в анимации Idle и не оборачивается, когда мы направляем его назад. Для этого, скрипт необходимо модифицировать ещё. Создаём метод, который определяет, в каком направлении смотрит Лукас:
void CheckWhereToFace ()
{
if (dirX > 0)
{
facingRight = true;
State = CharState.Walk;
}
if (dirX < 0)
{
facingRight = false;
State = CharState.Walk;
}
if (dirX == 0)
{
State = CharState.idle;
}
if (dirY < 0)
{
State = CharState.Walk;
}
if (dirY > 0)
{
State = CharState.Walk;
}
if (((facingRight) && (localScale.x < 0)) || ((!facingRight) && (localScale.x > 0)))
localScale.x *= -1;
transform.localScale = localScale;
Данная часть кода так же не является чем то трудным. Метод описывает, что если dirX > 0 (если мы идём направо), то поворачиваем спрайт в данном направлении и запускаем анимацию ходьбы. Если меньше 0, то разворачиваем Лукаса на 180 градусов и запускаем анимацию. Если dirX равна нулю, значит Лукас стоит и нужно запустить анимацию ожидания.
Почему в данном случае использовать операцию с Scale предпочтительнее, чем использовать flipX = true? В дальнейшем, я буду описывать возможность брать какие-либо предметы в руки и естественно, Лукас может разворачиваться держа в руках что-либо. Если бы я использовал обычное отражение, то предмет, который бы я держал в руках так и остался бы с правой стороны (например), когда Лукас смотрит налево и наоборот. Изменение масштаба переместит объект, который держит Лукас в ту-же сторону, куда обернулся сам Лукас.
Помещаем данную функцию в функцию Update(), для её покадрового мониторинга.
Отлично. Первые 2 пункта из 5 выполнены. Перейдём к потребностям Лукаса. Допустим, у Лукаса будет 3 вида потребностей, которые нужно удовлетворять, чтобы остаться в живых. Это уровень жизни, уровень голода и уровень жажды. Нужно создать для этого простую и понятную панель с индикатором каждого пункта. Чтобы создать такую панель, нужно щёлкнуть правой мышкой и выбрать UI = Panel.
Разметим её примерно так, как показано ниже
Панель состоит из трёх изображений (Image) каждой потребности (слева). Справа располагается сами панели. На первом слое (будем выражаться так), расположен цветовой индикатор (Image) не имеющий прозрачность, под ним скопирован объект Image отличающийся прозрачностью. Данный Image наполовину прозрачен оригинала. Так же Image, который не имеет прозрачности имеет свойство Image Type = Filled. Данная возможность позволит нам имитировать уменьшение наполненности шкалы потребности.
Определяем новые статичные поля:
/* Определяем переменные, относящийся к жизни героя */
[SerializeField]
public static float Health = 100, Eat = 100, Water = 100, _Eat = 0.05f, _Water = 0.1f; // Данные переменные будут отвечать за основные потребности героя. Переменные с приставков _ Отнимают потребности.
/* Конец определения переменных, относящийся к жизни героя */
/* Определяем переменные, относящийся к отображению потребностей героя */
[SerializeField]
Image iHealt, iEat, iWater; // Данные поля назначаются в редакторе
/* Конец определения переменных, относящийся к отображению потребностей героя */
В данном случае я использую статичные поля. Это сделано для того, чтобы данные поля были единственными для всего класса. Так же, это позволит нам напрямую обращаться к данным полям по имени класса. Пишем несколько простых функции:
private float fEat(float x)
{
Eat = Eat - x * Time.deltaTime;
iEat.fillAmount = Eat / 100f; // Строчка, которая отвечает за заполнение не прозрачного индикатора
return Eat;
}
private float fWater(float x)
{
Water = Water - x * Time.deltaTime;
iWater.fillAmount = Water / 100;
return Water;
}
Затем, пишем метод, который собирает информацию о желании есть и пить:
private void Needs()
{
if (fEat(_Eat) < 0)
{
Debug.Log(Eat);
}
else if (fEat(0) == 0)
{
StartCoroutine(ifDie());
}
if (fWater(_Water) < 0)
{
Debug.Log(Water);
}
else if (fWater(0) == 0)
{
StartCoroutine(ifDie());
}
Функция Needs() помещена в функцию Update() и вызывается каждый кадр. Соответственно, в строках if (fEat(_Eat) < 0) вызывается функция, которая передаёт в качестве параметра сколько необходимо отнять от переменной Eat и Water. Если по результату выполнения функции вернулся не 0, то значит Лукас ещё не умер от жажды или голода. Если Лукас всё-таки погибает от голода или смертельного ранения, то мы выполняем корутин StartCoroutine(ifDie());, который запускает анимацию смерти и перезапускает уровень:
IEnumerator ifDie()
{
State = CharState.Die;
yield return new WaitForSeconds(2);
SceneManager.LoadScene("WoodDay", LoadSceneMode.Single);
}
Твёрдый тайл
Игровой объект, не позволяющий игроку пройти сквозь него. Пример: пол в Super Mario Bros (Nintendo, 1985 год).
Для того, чтобы реализовать землю и не дать Лукасу падать сквозь неё, необходимо подключить к Лукасу компоненты BoxCollider2D и Rigidbody2D. Так же, понадобится спрайт земли, на котором будет находиться компонент BoxCollider2D. Компонент BoxCollider2D реализует столкновение коллайдеров и их поведение при столкновении. На данном этапе нам не нужно ничего, кроме как не допускать провалы Лукаса под землю. Всё, что нам по желанию можно отредактировать, это границы коллайдера. В моём случае, спрайт земли имеет поверхность травы и для того, чтобы не казалось, будто трава способна выдержать вес Лукаса, я отредактирую границы компонента.
Теперь, увлекательный процесс разметки уровня. Для удобства, можно экспортировать данный кубик земли в префаб. Префаб, это контейнер игрового объекта, при модификации которого можно автоматически применить изменения на все созданные от данного префаба игровые объекты. Далее, клонируем данный префаб клавишами CTRL+D (предварительно выбрав его во вкладке иерархии) и размешаем на сцене.
Экран
Часть уровня/мира игры, в данный момент видимая игроку.
Настроим камеру, которая будет следить за игроком о отображать часть сцены. Далее, будет очень простой в реализации скрипт:
public GameObject objectToFollow;
public float speed = 2.0f;
void Update ()
{
CamFoll();
}
private void CamFoll()
{
float interpolation = speed * Time.deltaTime;
Vector3 position = this.transform.position;
position.y = Mathf.Lerp(this.transform.position.y, objectToFollow.transform.position.y, interpolation);
position.x = Mathf.Lerp(this.transform.position.x, objectToFollow.transform.position.x, interpolation);
this.transform.position = position;
}
В поле objectToFollow типа GameObject будет назначен объект, за которым необходимо следить, а в поле speed скорость, с которой необходимо плавно перемещаться за назначенным GameObject.
В поле interpolation записывается информация о скорости перемещения с момента последнего кадра. Далее будет использоваться метод Lerp, который и обеспечит плавное перемещение камеры за Лукасом при его перемещении по Оси Х и У. К сожалению, я не смогу объяснить работу строчки
position.y = Mathf.Lerp(this.transform.position.y, objectToFollow.transform.position.y, interpolation);
с точки зрения математики. Поэтому, скажу проще — данный метод позволит растянуть по времени выполнения какого-либо действия. В нашем случае — это перемещение камеры за объектом.
Опасность
Сущности, мешающие игроку выполнить его задачу. Пример: шипы из 1001 Spikes (Nicalis and 8bits Fanatics, 2014 год).
Начнём добавлять что-то, что будет не просто мешать Лукасу пройти сцену до конца, а будет влиять на количество его жизней и возможность погибнуть (за одно, реализуем пятую подзадачу по реализации предания жизни Лукасу — Герой может быть убит или может погибнуть).
В данном случае, разбросаем по сцене шипы, которые будут спрятаны за растительность и только внимательность играющего поможет пройти мимо.
Создадим пустой GameObject и подключим к нему компоненты SpriteRenderer и PolygonCollider2D. В компоненте SpriteRenderer подключаем спрайт колючей кнопки или любого другого объекта по желанию. Так же, назначаем шипу tag = Thorn.
Далее, на GameObject Лукаса создаём скрипт, который будет отвечать за то, что будет происходить с ним, если Лукас будет сталкиваться с другими коллайдерами. В моё случае я назвал его ColliderReaction.cs
private Rigidbody2D rb2d;
void Start()
{
rb2d = GetComponent<Rigidbody2D>();
}
public void OnTriggerEnter2D(Collider2D collision)
{
switch (collision.gameObject.tag)
{
case "Thorn":
{
rb2d.AddForce(transform.up * 4, ForceMode2D.Impulse);
HeroScript.Health = HeroScript.Health - 5;
}
break;
}
}
Суть скрипта проста как 2х2. Когда происходит коллизия с игровым объектом по тегу Thorn, то оператор Switch сравнивает с кандидатами, которые мы указали. В нашем случае пока, это Thorn. Во-первых, Лукаса подбрасывает вверх, а затем мы обращаемся к статичному полю и отнимаем 5 единиц жизни у Лукаса. Забегая вперёд, могу сказать, что есть смысл описать тоже самое для коллизии с врагами:
case "Enemy":
{
rb2d.AddForce(transform.up * 2, ForceMode2D.Impulse);
HeroScript.Health = HeroScript.Health - 10;
}
break;
Далее, предлагаю убить двух зайцев одним выстрелом
Собираемый предмет и Правило.
Игровой объект, который могут подбирать игроки.
Предложим правило, что если Лукас хочет проходить между островками и подниматься наверх, то нужно собирать дерево для того, чтобы строить мосты и лестницы.
По уже пройденным способам создадим дерево и лестницы.
К дереву подключим скрипт, который будет отвечать за то, сколько брёвен с него можно выбить, если начать его рубить. Поскольку в наборе спрайтов была предложена только анимация атаки, будем использовать её, когда рубим дерево (издержки производства).
Скрипт, который находиться на дереве:
[SerializeField]
private Transform inst; // откуда будут падать брёвна
[SerializeField]
private GameObject FireWoodPref; // префаб бревна
[SerializeField]
private int fireWood; // сколько брёвен может выпасть с одного дерева
При запуске уровня записываем в fireWood случайное значение:
void Awake()
{
fireWood = Random.Range(4,10);
}
Описывает метод с параметром, который будет отвечать за то, сколько брёвен будет выпадать за один удар:
public int fireWoodCounter(int x)
{
for (int i = 0; i < fireWood; i++)
{
fireWood = fireWood - x;
InstantiateFireWood();
}
return fireWood;
}
Метод, который будет создавать на сцене клоны брёвен.
private void InstantiateFireWood():
{
Instantiate(FireWoodPref, inst.position, inst.rotation);
}
Создадим бревно и подключим к нему скрипт со следующим кодом:
public void OnTriggerEnter2D(Collider2D collision)
{
switch (collision.gameObject.tag)
{
case "Player":
{
if (InventoryOnHero.woodCount > 10)
{
Debug.Log("Больше дерева унести нельзя!");
}
else
{
InventoryOnHero.woodCount = InventoryOnHero.woodCount + 1;
Destroy(this.gameObject);
}
}
break;
}
}
Далее, мы так же создадим класс, который будет отвечать за инвентарь.
Сперва проводиться проверка, есть ли место в сумке. Если нет, то ошибка и бревно остаётся лежать, если место есть, то пополняем инвентарь на одну единицу и уничтожаем бревно.
Далее, необходимо что-то делать с этими ресурсами. Как говорилось выше, предложим игроку возможность строить мосты и лестницы.
Для создания моста нам потребуется 2 префаба с левой и правой половиной моста. На каждой половине будет подключен компонент BoxCollider2D. Так же, чтобы игроку было понятно, что в этом месте нужно что-то построить, сделаем таблички.
К табличке подключим основной скрипт со следующим кодом:
[SerializeField]
private Transform inst1, inst2; // место появления первой и второй половины моста
[SerializeField]
private GameObject bridgePref1, bridgePref2; // сами префабы
[SerializeField]
private int BridgeCount; // сколько нужно дерева, чтобы построить мост. Назначаем в редакторе
Затем:
public void BuildBridge()
{
if (InventoryOnHero.woodCount == 0)
{
Debug.LogWarning ("Нет дерева!");
}
if (InventoryOnHero.woodCount > 0)
{
BridgeCount = BridgeCount - 1;
InventoryOnHero.woodCount = InventoryOnHero.woodCount - 1;
}
switch (BridgeCount)
{
case 5: Inst1();
break;
case 0: Inst2();
break;
default: Debug.LogWarning("Что-то пошло не так со строительством моста");
break;
}
}
Логика следующая, в редакторе мы назначаем, сколько брёвен потребуется на строительство любого моста. Одному, может потребоваться 10 брёвен, другому 12 или 8.
Сперва, проверяем, есть ли вообще брёвна у Лукаса, если нет, то не делаем ничего. Если есть, то отнимаем 1 от количества, необходимого для строительства и отнимаем 1 в инвентаре Лукаса. Когда оставшейся количества брёвен, которые нужны для моста равно 5, то вызывается метод, который создаёт клон префаба с первой половиной моста. Если 0, то вторая половина. По итогу, получается полноценный мост.
Лестница делается по тому же сценарию.
Для подъёма по лестнице, необходимо модифицировать ColliderReaction.cs:
void OnTriggerStay2D(Collider2D collision)
{
switch (collision.gameObject.tag)
{
case "Ladder":
{
rb2d.gravityScale = 0;
}
break;
}
}
void OnTriggerExit2D(Collider2D collision)
{
switch (collision.gameObject.tag)
{
case "Ladder":
{
rb2d.gravityScale = 1;
}
break;
}
}
Метод OnTriggerStay2D определяет, находится ли Лукас до сих пор внутри лестницы или нет. Если да, то делаем гравитацию равную 0. Тогда, Лукас сможет спокойно подниматься вверх и спускаться вниз. Метод OnTriggerExit2D делает следующее, при выходе из лестницы мы возвращаем Лукасу гравитацию и снова способен ходить по земле.
Враги
Опасность, принимающая вид персонажа.
Я напечатал уже 19 страниц документа и переживаю, что всю эту нудятину за один заход читать никто не станет. Поэтому, предлагаю завершить на создании врагов данную статью, взять передышку на неделю, почитать отзывы, исправить ошибки и внести разные дополнения, если таковые будут.
Создаём пустой GO, на который добавляем уже известные SpriteRenderer, BoxCollider2D, Rigidbody2D. Далее, определять что именно видит Лукас — дерево, приглашение строить мост или врагов будем по маске. Ссылку на крайне простое и понятное объяснение, что такое слои я оставлю ссылкой в конце на ru.stackoverflow.com.
Я напечатал уже 19 страниц документа и переживаю, что всю эту нудятину за один заход читать никто не станет. Поэтому, предлагаю завершить на создании врагов данную статью, взять передышку на неделю, почитать отзывы, исправить ошибки и внести разные дополнения, если таковые будут. Создаём пустой GO, на который добавляем уже известные SpriteRenderer, BoxCollider2D, Rigidbody2D. Далее, определять что именно видит Лукас — дерево, приглашение строить мост или врагов будем по маске. Ссылку на крайне простое и понятное объяснение, что такое слои я оставлю ссылкой в конце на ru.stackoverflow.com.
Деревья у меня так же помещены в слой Trees.
Фактически, есть множество способов заставить врагов контролировать некоторый периметр. Например, в своей предыдущей демо-версии, которую я опубликовал в плей маркете, враги в основном стояли на месте и с помощью Raycast из них выходило по 2 луча в каждом направлении (4 луча в сумме). Первый луч, самый длинный, отвечал за расстояние, на котором противник видел Кадавара (так звать того рыцаря). Как только Кадавар попадал в луч видимости (в каждом из двух направлений), то противник двигался в сторону Кадавара. Вторая пара лучей отвечала за то, как близко оказывался к ним Кадавар. Если Кадавар достигал второй луч, то срабатывала анимация удара по герою, Кадавара отбрасывало назад и отнималось несколько жизней. Конечно же, Кадавар бегал чуть быстрее своих врагов. Поэтому, после сильного удара и отбрасывания, он оказывался дальше первого луча и враги снова останавливались и ждали его вхождения в зону видимости (но фактически могли и продолжить движение, если Кадавар не отлетел слишком далеко).
В данной игре, я решил попробовать другой подход. Каждый Зелёный монстр, будет контролировать свой участок в пределах какой-либо дистанции.
Код контроля территории выглядит так:
[SerializeField]
private GameObject area;
private bool m1 = true, m2; / m значит move
private void fGreenMonster()
{
float dist = Vector3.Distance(greenMonster.transform.position, area.transform.position);
Debug.Log(dist);
if (m1)
{
if (dist < 3f)
{
transform.position += new Vector3(speed,0,0) * Time.deltaTime;
SR.flipX = true;
}
else
{
m1 = false;
m2 = true;
}
}
if (m2)
{
if(dist >= 1f)
{
transform.position += new Vector3(-speed,0,0) * Time.deltaTime;
SR.flipX = false;
}
else
{
m2 = false;
m1 = true;
}
}
}
Помещаем данный метод в метод Update() и каждый кадр проверяем, как далеко от начальной точки находится Зелёный монстр. Если дистанция меньше, чем 3 единицы, то идём направо. Если достигну 3, значит просто разворачивается спрайт и мы идём налево, по направлению к точке начала.
Так же, в одном скрипте у меня реализована атака злых подсолнухов.
private void fSunFlower()
{
canBullet = canBullet - minus * Time.deltaTime;
if (canBullet <= 0 && SR.flipX == false)
{
GameObject newArrow = Instantiate(sunFlowerBullet) as GameObject;
newArrow.transform.position = transform.position;
Rigidbody2D rb = newArrow.GetComponent<Rigidbody2D>();
rb.velocity = sunFlowerTrans.transform.forward * -sunFlowerBulletSpeed;
canBullet = 2;
}
if (canBullet <= 0 && SR.flipX == true)
{
GameObject newArrow = Instantiate(sunFlowerBullet) as GameObject;
newArrow.transform.position = transform.position;
Rigidbody2D rb = newArrow.GetComponent<Rigidbody2D>();
rb.velocity = sunFlowerTrans.transform.forward * sunFlowerBulletSpeed;
canBullet = 2;
}
canBullet = canBullet - minus * Time.deltaTime;
Отсчитываем промежуток времени, через который необходимо выпустить следующий снаряд.
if (canBullet <= 0 && SR.flipX == false)
{
GameObject newArrow = Instantiate(sunFlowerBullet)
}
Так же, небольшой код, который отвечает за количество жизни, который нужно отнять, когда Лукас их атакует:
public int Damage(int x)
{
Health = Health - x;
return Health;
}
Далее, проверяем, сколько осталось жизни у монстров:
public void ifDie()
{
if (Damage(0) <= 0)
{
Destroy(this.gameObject);
}
}
Если жизней 0, то уничтожаем объект.
Кстати замечу, что у меня один скрипт на два вида противников:
if (bGreenMonster)
{
fGreenMonster();
}
if (bSunFlower)
{
fSunFlower();
}
В редакторе назначаем, к какому типу противников относится тот или иной префаб.
Вишенка на торте.
Как определить, кто или что находится перед Лукасом?
Первое, назначаем каждой категории объектов свой определённый слой.
В моём случае всё выглядит так:
Определяем поля:
[SerializeField]
private Transform Hero; // Точка с которой мы начнём начало для отслеживания
[SerializeField]
private float distWhatHeroSee; // Радиус отслеживания
[SerializeField]
private LayerMask Tree, BridgeBuild, LadderBuild ,drinkingWater, lEnemy; // Наши маски
Я скопирую только часть метода атаки, так как остальные части в основном повторяют свою логику:
private void AttackBtn()
{
if (CrossPlatformInputManager.GetButtonDown("Attack"))
{
GameObject.Find("Hero").GetComponent<HeroScript>().State = CharState.AttackA;
Collider2D[] Trees = Physics2D.OverlapCircleAll(Hero.position, distWhatHeroSee, Tree);
for (int i = 0; i < Trees.Length; i++)
{
Trees[i].GetComponent<TreeControl>().fireWoodCounter(1);
Debug.Log("Trees Collider");
HeroScript.Water = HeroScript.Water - 0.7f;
}
// BB значит BridgeBuild
Collider2D[] BB = Physics2D.OverlapCircleAll(Hero.position, distWhatHeroSee, BridgeBuild);
for (int i = 0; i < BB.Length; i++)
{
BB[i].GetComponent<BridgeBuilding>().BuildBridge();
HeroScript.Water = HeroScript.Water - 0.17f;
}
GameObject.Find("Hero").GetComponent<HeroScript>().State = CharState.AttackA;
Поскольку мы ограничены только анимацией атаки, то всегда при нажатии на кнопку будет запускаться именно она.
Далее, создаём массив коллайдеров:
Collider2D[] Trees = Physics2D.OverlapCircleAll(Hero.position, distWhatHeroSee, Tree);
for (int i = 0; i < Trees.Length; i++)
{
Trees[i].GetComponent<TreeControl>().fireWoodCounter(1);
Debug.Log("Trees Collider");
HeroScript.Water = HeroScript.Water - 0.7f;
}
В переменную Trees записываем, сколько деревьев попало в зону видимости. В цикле прогоняем каждый попавший префаб, получаем компонент скрипта и запускаем метод с параметром, который был описан выше как для дерева, так и для врагов. В конце отнимаем немного воды в качестве потраченной энергии.
По аналогии, вы легко проделаете тоже самое и для врагов и для строительства моста. Simple as that!
На данный момент, демо-сцена у меня выглядит так:
Благодарю вас за прочтение статьи и, как я написал в самом начале — жду критики.
На данный момент, проект отправлен на модерацию в плеймаркет. Так же, я сейчас не хочу выкладывать проект в открытый доступ, хотя бы просто потому, что не везде оформил комментарии.
Приблизительно через 2 недели я хочу дополнить игру ещё одним уровнем, где будет реализованна остальная часть паттернов и такие важные моменты работы с ПЗУ как сохранение и восстановление данных после прерывания игры.
Удачи!
Все игровые объекты были взяты с сайта https://opengameart.org/
Автор: Denis_Andreevich