Взаимодействие с окружением без коллайдеров и лучей, на простой математике
Бонус урока. Делаем простое пианино!
Наверно всем знаком такой элемент в игре, как всплывающая иконка рядом с игровым объектом, позволяющая с ним интерактивно взаимодействовать.
Интерактивную иконку для взаимодействия с окружением будем далее называть меткой.
Статья подходит для начинающих программистов, которые хотят сделать просто и понятно. Также материал будет полезен в качестве обучения при работе с кодом.
В данном уроке все-таки понадобится небольшой элемент творчества и базовый навы�� работы со скриптами, простого копирования здесь не хватит.
Это мой первый урок по программированию на Unity, поэтому прошу сильно не бить критика будет уместна. Если что не очень понятно – эти моменты разберу подробнее.
Система взаимодействия метки с игроком будет состоять из 2 скриптов.
Первый скрипт будет расположен на самой метке. А метка будет крепиться к нужному объекту в 3D мире (хотя можно ипользовать и чисто одну метку, например, для заскриптованных действий).
Скрипты для каждой метки будут работать независимо друг от друга.
Поэтому понадобится общий скрипт.
2 скрипт основной (общий для меток) – он будет отслеживать все интерактивные метки-объекты в игре, и выбирать активной самую близкую к игроку (отключая активность остальных). Таким образом, в игре не будет путаницы при очень близком расположении объектов.
Приступим!
Урок будет разделен на 2 главы.
В первой главе расскажу базовую работу метки и как ее прикрепить к чему-либо в игре, без объяснения тонкостей кода.
А во второй главе подробно рассмотрим, как все работает изнутри. Будет полезно для тех, кто хочет доделать алгоритм для себя.
Содержание
Делаем метки для взаимодействия с окружением:
Изучаем скрипты:
0. Основа
Глава 1. Делаем метки для взаимодействия с окружением
Принцип работы метки:
Подходя на определенное расстояние к объекту с меткой – метка становится видимой (=расстояние включения ).
Подходя очень близко –
включается активный режим метки (иконка меняет цвет) и появляется текст подсказки, например: “Нажмите клавишу E для открытия двери”. Можно нажать нужную кнопку и будет выполнено ваше действие (скрипт). После этого метку можно удалить с объекта или оставить для дальнейшего использования.
Метка является спрайтом, расположенным в мировых координатах (не видно за другими объектами). Ее можно смещать относительно самого объекта в удобное положение (спереди, сбоку, выше и т.д.).
Для активации метки среди большого количества объектов, первостепенно проверяется угол положения метки относительно камеры. Наиболее близкая к центру экрана, далее уже проверяется на нужное расстояние (подробнее во второй главе).
С помощью данного функционала можно реализовать недоступность нажатия метки, расположенной через стену от игрока или за другими объектами. Для этого в параметрах метки можно поставить маленькое расстояние активации, и тогда метка станет активной только при близком нахождении игрока.
Посмотреть работу интерактивных меток в действии можно на видео (сцена MarkScene из проекта):
Передвижение W A S D или стрелки на клавиатуре. Использовать метку – клавиша E или клик левой кнопкой мыши. Пауза – пробел.
Архив всего проекта в конце статьи.
Все что вы видите на сцене, сделано для примера работы с метками. В вашем проекте можно сделать все что угодно.
Теперь рассмотрим, как добавлять метки в ваш проект. Для примера создадим новую сцену TestScene (В вашем случае это может быть уже готовая сцена в проекте). И выполним минимальный набор действий для работоспособности метки.
Важно: часть описанных действий, возможно, придется переделать под ваш проект (в основном это переименование имен в скрипте).
Копируем в ваш проект папку Mark из исходника:

Создаем на сцене отдельный элемент для скриптов (чтобы они работали при запуске приложения). Если у вас есть такой элемент, продолжаем:

Добавим этому элементу тег SceneScript, чтобы другие объекты могли найти его и по тегу:

Новый тег можно создать, выбрав в этом списке Add Tag...
Перенесем в этот элемент скрипт Assets – Prefabs – Mark – Script – MainScriptMark.cs :

В поле Scene Script переносим ваш скрипт сцены.
Под скриптом сцены понимается скрипт вашего проекта, в котором уже есть какая-то логика и можно просто ее доработать. Или создать новый скрипт.
Если имя вашего основного скрипта сцены отличается от SceneScript
Переименуйте его в скриптах Mark.cs и MainScriptMark.cs:


Чтобы задать свои клавиши для активации меток (по умолчанию, клавиша E или левый клик мыши), можно отредактировать код в функции Update скрипта MainScriptMark.cs:
if (Input.GetKeyDown(KeyCode.E) || Input.GetMouseButtonDown(0))
{
if (active_mark != null)
UseMark();
}В вашей игре может понадобиться пауза.
В текущем проекте пауза реализована таким способом - в скрипте сцены переменная game_status хранит текущий статус игры.
Например, на паузе game_status будет равен 0. А в обычной игре game_status будет равен 1.
Если вы будете использовать паузу в игре, то в вашем скрипте сцены должна быть функция:
public bool GameWorking()
{
bool ret = true;
if (game_status != 1) // переменная хранит текущее состояние игры
ret = false;
return ret;
}Другие объекты, вызывая в скрипте сцены публичный метод GameWorking(), будут знать о те��ущем состоянии игрового процесса, и, например, во время паузы останавливать работу, приглушать звук и т.д.
При включении паузы все метки переводятся в неактивное состояние с помощью вызова функции RemoveAllMark() в общем скрипте меток.
Далее, пока GameWorking() возвращает нулевое значение, сами метки активироваться не будут.
Если вам такая логика не нужна, то все что связано с паузой, можно убрать.
Для работы с текстом я использую библиотеку TextMeshPro.
Установка TextMeshPro
Для установки пакета заходим во вкладку Window – Package Manager, выбираем раздел Unity Registry и находим TextMeshPro, и затем скачиваем и добавляем в проект:

Сам текстовой элемент можно добавить правой кнопкой мыши в Hierarchy:

Если вы используете другой способ работы с текстом, то вам не составит труда отредактировать нужные строки скрипта MainScriptMark под другой текстовой класс.
Для плавной смены значений переменных и анимации я использую DOTween.
Установка DOTween
Теперь добавим на сцену ваш объект, для которого будет использоваться метка. Для этого создадим пустой объект:

А затем в него перенесем ваш меш объекта. И на этом же уровне вложенности добавим префаб метки:

Саму метку можно (и нужно) сместить в удобную точку относительно объекта.
Для примера, создадим обычный куб, и перетащим центр метки чуть выше самого куба:

В завершение, можно создать префаб объекта.
Чтобы сделать префаб нового объекта
Переносим родительский объект в любую папку проекта, например Prefabs:

Все готово! Наконец-то
Чтобы проверить работоспособность на пустой сцене – в инспекторе двигаем камеру ближе к объекту. Метка сначала появляется, а вблизи меняет цвет. Отлично!
Дальнейшую настройку функционала рассмотрим во второй главе.
Осталось настроить параметры метки на ваш вкус:

Mark type – Тип действия для метки. Например, у вас в игре много дверей, которые нужно открывать. И здесь вы задаете одинаковый номер для такого однотипного действия.
Главное запомнить этот номер, дальше он будет использоваться в скрипте.
Mark status – если одна метка нужна для разных действий (например, открыть, а потом закрыть и т.д.), здесь задаем номер начального действия при запуске приложения. В скрипте в зависимости от этого значения нужно будет написать начальные положения объектов.
Attach obj - ссылка на объект, над которым будут производиться действия. Перемещаем сюда конкретный вложенный объект (например, если нужно поворачивать дверь при открытии, то указываем объект с нужным transform. Если нужно выполнить отдельный скрипт, указываем ссылку на объект, на котором расположен этот скрипт). Затем из скрипта метки можно будет легко обратиться к этому объекту.
Cof scale – коэффициент масштаба метки. Значение напрямую влияет на видимый размер на экране.
Distanse active – расстояние от камеры до метки, при котором метка станет интерактивной.
Distanse show – расстояние от камеры до метки, при котором метка будет видимой.
Enable – дополнительный параметр, который позволяет не использовать эту метку (не удаляя сам компонент).
Angle active – в скрипте используется расчет угла обзора. Если угол направления от камеры к метке в пределах этого значения, то метка перейдет из видимого состояния в активное.
Color Show – цвет включенной метки.
Остальные цвета можно настроить в скрипте, или вывести в виде переменной.
В следующей главе рассмотрим код проекта.
Глава 2. Изучаем скрипты
Самые важные элементы при работе с метками, это переменные mark_type и mark_status.
mark_type - число задается в инспекторе при настройке метки, а также именно по этому же значению в коде выставляются другие параметры и выполняются действия. Например, открытие двери, включение лампочки, подбор предмета – это все разные типы действий, поэтому для них можно поставить уникальный mark_type.
mark_status - Это своего рода состояние, в котором находится объект метки. Например, 0 – выключен, 1 – загружается, 2 – включен. 3 – сломан, и т.д. Это значение можно задать в инспекторе, а затем в коде выполнять нужные действия для объекта, на основе этой переменной. Например, есть выдвижной ящик, с mark_status = 0 он будет закрытым. Нажав по метке, ящик откроется, и состояние поменяется на mark_status = 1. Тогда следующее действие уже будет закрывать его.
Здесь я не буду писать построчно весь код, а сразу начнем разбирать, как и что работает.
Интересно услышать ваше мнение, насколько понятно написан материал в этой главе).
1. Функция LateUpdate из скрипта Mark.cs
Это базовая функция для проверки текущего состояния метки (state = 0, выключена, 1 = интерактивная, 2 = просто видимая).
Код функции LateUpdate
private void LateUpdate()
{
if (enable)
{
distanse = Vector3.Distance(main_cam.position, tr.position);
if (distanse < distance_show)
{
if (!show)
{
render.enabled = true;
show = true;
}
if (state == 1 || state == 2)
angle_to_object = Mathf.Abs(Vector3.SignedAngle(tr.position - main_cam.position, main_cam.forward, Vector3.right));
if (state != 1 && distanse <= distance_active && angle_to_object <= angle_active && (script_scene == null || (script_scene != null && script_scene.GameWorking()) ) )
ChangeState(1);
else if ((state != 2 && distanse > distance_active) || (state == 1 && angle_to_object > angle_active) || (state == 1 && priority_stop))
ChangeState(2);
}
else if (state != 0 && distanse > distance_show + 1)
ChangeState(0);
if (show)
{
tr.rotation = main_cam.rotation;
sprite_size = cof_scale * distanse;
Vector3 scale_cof = new Vector3(tr.localScale.x / tr.lossyScale.x, tr.localScale.y / tr.lossyScale.y, tr.localScale.z / tr.lossyScale.z);
tr.localScale = Vector3.Slerp(tr.localScale, scale_cof * sprite_size, Time.deltaTime * 10);
}
}
}
Функция каждый кадр отслеживает расстояние до метки. Масштабирует иконку и разворачивает ее в сторону игрока.
Сначала определяем текущее расстояние от камеры до метки:
distanse = Vector3.Distance(main_cam.position, tr.position);
Если это расстояние меньше дистанции видимости:
if (distanse < distance_show)
то включаем графическое отображение метки (если до этого была выключена):
if (!show)
{
render.enabled = true;
show = true;
}Проверяем состояние метки в прошлом кадре, и если она уже была включена (1 – активна, 2 – просто видимая), находим угол между меткой и направлением камеры для дальнейшего использования:
if (state == 1 || state == 2)
angle_to_object = Mathf.Abs(Vector3.SignedAngle(tr.position - main_cam.position, main_cam.forward, Vector3.right)); Метод
SignedAngleпозволяет находить угол в виде сферического угла обзора (не только по горизонтали, но и по вертикали).
Дальше проверяем условие перехода в активное состояние (state = 1):
if (state != 1 && distanse <= distance_active && angle_to_object <= angle_active && script_scene.GameWorking())
ChangeState(1); Если метка сейчас в другом состоянии, и расстояние до камеры входит в заданное расстояние активности (distance_active), и угол между направлением камеры и меткой в пределах заданного (angle_active), и скрипт сцены разрешает сейчас активировать метку (например, игра не на паузе), то запускаем функцию ChangeState с аргументом 1. Функцию ChangeState рассмотрим позже.
Если условие для перехода активное состояние метки не выполнено, проверяем условие перехода в состояние обычной видимости (state = 2):
else if ((state != 2 && distanse > distance_active) || (state == 1 && angle_to_object > angle_active) || (state == 1 && priority_stop))
ChangeState(2);Если метка еще не в этом состоянии, и расстояние до камеры больше расстояние активности (distance_active), или метка сейчас в состоянии активности, но текущий угол обзора стал больше заданного пользователем (angle_active), или метка сейчас в состоянии активности, но это состояние принудительно нужно выключить общим скриптом меток ( например, в приоритете другая метка), то запускаем функцию ChangeState с аргументом 2.
Если вышестоящее условие if (distanse < distance_show) не было выполнено, то проверяем условие выключения метки:
else if (state != 0 && distanse > distance_show + 1)
ChangeState(0); Если метка еще не в выключенном состоянии и дистанция до камеры больше расстояния видимости плюс 1 метр (Здесь добавляем + 1 метр к расчету, чтобы не было постоянного включения / выключения при легком смещении игрока), то запускаем функцию ChangeState с аргументом 0.
Далее, если метка видна, смещаем метку параллельно камере и меняем ее масштаб:
if (show)
{
tr.rotation = main_cam.rotation;
sprite_size = cof_scale * distanse;
Vector3 scale_cof = new Vector3(tr.localScale.x / tr.lossyScale.x, tr.localScale.y / tr.lossyScale.y, tr.localScale.z / tr.lossyScale.z);
tr.localScale = Vector3.Slerp(tr.localScale, scale_cof * sprite_size, Time.deltaTime * 10);
}2. Функция ChangeState из скрипта Mark.cs
Код функции ChangeState
private void ChangeState(int num)
{
bool mark_access = true;
if (num == 1)
mark_access = script.NewActiveMark(this.gameObject, angle_to_object, mark_type, mark_status);
if (mark_access)
{
priority_stop = false;
if (state == 1 && num != 1)
script.RemoveActiveMark(this.gameObject);
state = num;
ChangeColor(state);
}
}После проверки изменения метки, эта функция устанавливает новое состояние метки.
Задаем переменную для проверки смены состояния, по умолчанию разрешено:
bool mark_access = true;
Если нужно поменять состояние на активное (функция запущена с аргументом 1), то, дополнительно проверяем в общем скрипте:
if (num == 1)
mark_access = script.NewActiveMark(this.gameObject, angle_to_object, mark_type, mark_status); Функция NewActiveMark будет рассмотрена позже. Она возвращает успешность активации метки. В нее передаем текущий объект метки, угол камеры к метке, тип и состояние метки.
Дальше выполняется блок кода:
if (mark_access)
{
priority_stop = false;
if (state == 1 && num != 1)
script.RemoveActiveMark(this.gameObject);
state = num;
ChangeColor();
}priority_stop (переменная, отвечающая за принудительное выключение активной метки из общего скрипта, если в приоритете находится другая метка) возвращаем в отключенное состояние.
Если новое состояние метки нужно сделать неактивным, но сейчас оно еще активное, то вызываем в общем скрипте функцию RemoveActiveMark для отключения активности этой метки, и в качества аргумента передаем текущий объект метки.
После всех изменений устанавливаем новое значение метки и меняем ее цвет.
3. Функция NewActiveMark из скрипта MainScriptMark.cs
Код функции NewActiveMark
public bool NewActiveMark(GameObject obj, float angle, int mark_type, int mark_status)
{
bool ret = false;
bool game_pause = false;
if (scene_script != null && !scene_script.GameWorking())
game_pause = true;
if (!game_pause)
{
if (active_mark != obj)
{
if (active_mark != null)
{
if (angle < active_mark.GetComponent<Mark>().GetAngle())
{
active_mark.GetComponent<Mark>().RemoveActiveState();
ret = true;
}
}
else ret = true;
}
if (ret)
{
active_mark = obj;
if (notice_txt != null)
{
string type_str = action_type_txt[mark_type - 1][mark_status];
notice_txt.text = type_str + " - <b><color=#ffffff>E</color></b>";
notice_txt.enabled = true;
EffectScaleTxt();
}
}
}
return ret;
}Данная функция чувствует знает обо всех метках в игре и из подходящих меток (самая близкая к центру камеры), делает активной только одну из них.
Если новая метка еще не является активной, то включаем ее. А если в этот момент была активна другая метка, то сравниваем углы расположения меток относительно направления камеры. Новая метка станет активной только в случае если она расположена под меньшим углом к камере (и в этом случае делаем запрос в скрипт старой метки для принудительного оповещения об ее отключении и штрафом) :
if (active_mark != obj)
{
if (active_mark != null)
{
if (angle < active_mark.GetComponent<Mark>().GetAngle())
{
active_mark.GetComponent<Mark>().RemoveActiveState();
ret = true;
}
}
else ret = true;
}Если новая метка стала активной, записываем ссылку на ее объект в переменную (для будущего отслеживания) и обновляем текстовое поле с использованием анимации:
if (ret)
{
active_mark = obj;
if (notice_txt != null)
{
string type_str = action_type_txt[mark_type - 1][mark_status];
notice_txt.text = type_str + " - <b><color=#ffffff>E</color></b>";
notice_txt.enabled = true;
EffectScaleTxt();
}
}4. Функция SetNoticeTxt из скрипта MainScriptMark.cs
Код функции SetNoticeTxt
private void SetNoticeTxt()
{
AddNoticeTxt(new string[] { "Уменьшить", "Увеличить" }); // для 1 mark_type
AddNoticeTxt(new string[] { "Поднять вверх 1 раз" }); // 2
AddNoticeTxt(new string[] { "Удалить" }); // 3
AddNoticeTxt(new string[] { "Сдвинуть вверх","Сдвинуть вниз" }); // 4
// для пианино
string[] piano_str = new string[] { "Ля #", "Си", "До", "До #", "Ре", "Ре #", "Ми", "Фа", "Фа #", "Соль", "Соль #", "Ля", "Ля #", "Си", "До", "До #" };
for (int i = 0; i < piano_str.Length; i++)
piano_str[i] += " <size=25>сыграть</size> ";
AddNoticeTxt(piano_str); // 5
// Для кубиков
AddNoticeTxt(new string[] { "середина", "верх", "низ" }); // 6
}В этой функции заполняем данные текстовых подсказок с помощью функции AddNoticeTxt([текстовой массив]).
Каждый вызов функции добавляет подсказки к типу метки mark_type, начиная со значения 1. А каждая текстовая фраза в коде уже будет соотноситься с mark_status, начиная со значения 0. Например, после выполнения действия, можно переключать mark_status, а вместе с ней будет меняться и текстовая подсказка для следующего действия.
Для однократного действия AddNoticeTxt(new string[] { "Открыть дверь" }); // будет использоваться для mark_type = 1
Для метки с двумя действиями AddNoticeTxt(new string[] {"Открыть дверь","Закрыть дверь"});// для mark_type = 2
Подсказка Открыть дверь будет показываться при mark_status = 0, а подсказка Закрыть дверь будет показываться при mark_status = 1.
Подсказки для пианино:
string[] piano_str = new string[] { "Ля #", "Си", "До", "До #", "Ре", "Ре #", "Ми", "Фа", "Фа #", "Соль", "Соль #", "Ля", "Ля #", "Си", "До", "До #" };
for (int i = 0; i < piano_str.Length; i++)
piano_str[i] += " сыграть ";
AddNoticeTxt(piano_str);Здесь создаем массив с названиями нот и передаем его в качестве аргумента функции AddNoticeTxt.
5. Функция MarkReady из скрипта MainScriptMark.cs
Код функции MarkReady
public void MarkReady (GameObject mark, int mark_type, int mark_status)
{
if (mark_type == 4)
{
if (mark_status == 0)
mark.GetComponent<Mark>().SetMarkActiveColor(1, 0);
else mark.GetComponent<Mark>().SetMarkActiveColor(1, 1);
}
if (mark_type == 6)
{
Transform cubic_tr = mark.GetComponent<Mark>().attach_obj.transform;
if (mark_status == 0)
cubic_tr.localScale = new Vector3(1, 1.5f, 1);
else if (mark_status == 1)
cubic_tr.localScale = new Vector3(1, 2f, 1);
else if (mark_status == 2)
cubic_tr.localScale = new Vector3(1, 2.5f, 1);
}
}Функция MarkReady запускается каждой меткой при появлении на сцене.
В данной функции можно прописать начальные параметры для объектов (например, в зависимости от начального состояния mark_status метки, передвигаем объект в определенную позицию, или закрытую дверь делаем сразу открытой). А далее изменения уже происходят при активации меток (функция UseMark).
Рассмотрим метку с mark_type = 6, которая переключается между 3 состояниями (mark_status), изменяя высоту объекта.
Сохраняем в переменную cubic_tr ссылку на transform (при создании метки в публичное поле attach_obj можно указать объект для быстрого взаимодействия) управляемого объекта. А затем в зависимости от начального mark_status меняем масштаб объекта по высоте:
if (mark_type == 6)
{
Transform cubic_tr = mark.GetComponent<Mark>().attach_obj.transform;
if (mark_status == 0)
cubic_tr.localScale = new Vector3(1, 1.5f, 1);
else if (mark_status == 1)
cubic_tr.localScale = new Vector3(1, 2f, 1);
else if (mark_status == 2)
cubic_tr.localScale = new Vector3(1, 2.5f, 1);
}6. Функция UseMark из скрипта MainScriptMark.cs
Код функции UseMark
private void UseMark()
{
if (notice_txt != null)
notice_txt.enabled = false;
Mark mark = active_mark.GetComponent<Mark>();
int cur_mark_type = mark.GetMarkType();
int cur_mark_status = mark.GetMarkStatus();
GameObject cur_obj = mark.GetMarkObj();
if (cur_mark_type == 6) // кубики, меняем их высоту
{
cur_mark_status += 1;
if (cur_mark_status > 2)
cur_mark_status = 0;
float new_scaleY = 1.5f; // для mark_status = 0
if (cur_mark_status == 1)
new_scaleY = 2f; // для 1
if (cur_mark_status == 2)
new_scaleY = 2.5f; // для 2
cur_obj.transform.DOScale(new Vector3(1, new_scaleY, 1), 0.5f);
mark.SetMarkStatus(cur_mark_status);
RefreshTxtNotice(action_type_txt[cur_mark_type - 1][cur_mark_status]);
}
}Функция запускается непосредственно после нажатия клавиши по активной метке.
В ней перебираем все значения mark_type и прописываем свое действие на каждый тип метки.
Вспомнить, где задавали mark_type

После активации действия сразу скрываем текстовую подсказку. Затем кешируем активную метку в переменную mark, и получаем текущий тип метки, ее статус и управляемый ею объект:
if (notice_txt != null)
notice_txt.enabled = false;
Mark mark = active_mark.GetComponent<Mark>();
int cur_mark_type = mark.GetMarkType();
int cur_mark_status = mark.GetMarkStatus();
GameObject cur_obj = mark.GetMarkObj();Рассмотрим код действия для mark_type = 6 (Для этого объекта мы задавали начальные значения в функции MarkReady):
if (cur_mark_type == 6)
{
cur_mark_status += 1;
if (cur_mark_status > 2)
cur_mark_status = 0;
float new_scaleY = 1.5f; // масштаб для mark_status = 0
if (cur_mark_status == 1)
new_scaleY = 2f; // для 1
if (cur_mark_status == 2)
new_scaleY = 2.5f; // для 2
cur_obj.transform.DOScale(new Vector3(1, new_scaleY, 1), 0.5f); //плавная анимация
mark.SetMarkStatus(cur_mark_status);
RefreshTxtNotice(action_type_txt[cur_mark_type - 1][cur_mark_status]);
}Переключаем состояние метки cur_mark_status на следующее. Затем меняем масштаб объекта (который привязан к этой метке) в зависимости от нового состояния.
Новое состояние метки обязательно передаем в локальный скрипт метки mark.SetMarkStatus(cur_mark_status);
Т.к. данная метка используется для нескольких действий, нужно обновить текстовое поле для показа новой подсказки, вызывая функцию RefreshTxtNotice.
Заключение: мы успешно разобрали функционал работы с интерактивными метками. Теперь вы сможете наполнить свой проект большим разнообразием интерактивного взаимодействия с предметами.
В качестве упражнения попробуйте разобрать, как в проекте работает пианино:).
Усложненную версию данного функционала меток (реализация иконок в виде системы частиц) можно посмотреть на видео:
UPD 21.05.23 – Доработан функционал, улучшена читаемость кода.
UPD 28.05.23 – Дописан код масштабирования иконок. Теперь родительские элементы можно масштабировать отдельно, без нарушения пропорций показываемой иконки.
