Простой зомби-шутер на Unity ch. 2

Простой зомби-шутер на Unity ch. 2

Life-Hack [Жизнь-Взлом]/Хакинг

#Обучение 

Я добавил курсору поворот на 90 градусов вокруг оси Х, чтобы он лежал горизонтально на земле, задал масштаб 0.25, чтобы он не был таким большим и указал в качестве координаты Y значение 0.01. Последнее важно, чтобы не было эффекта, называемого “Z-fighting”. Видеокарта использует вычисления с плавающей запятой, чтобы определить, какие объекты находятся ближе к камере. Если задать курсору значение 0 (т.е. такое же, как и у плоскости земли), то из-за погрешностей в этих вычислениях, для некоторых пикселей видеокарта решит, что курсор ближе, а для других — что земля. Причем в разных кадрах наборы пикселей будут разные, что создаст неприятный эффект просвечивания кусков курсора сквозь землю и “мерцания” при его движении. Значение 0.01 достаточно велико, чтобы нивелировать погрешности в расчетах видеокарты, но при этом не настолько большое, чтобы глаз заметил, что курсор висит в воздухе.

Теперь переименуем игровой объект в Cursor и создадим скрипт с таким же названием и следующим содержанием:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Cursor : MonoBehaviour
{
    SpriteRenderer spriteRenderer;
    int layerMask;

    // Start is called before the first frame update
    void Start()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();
        layerMask = LayerMask.GetMask("Ground");
    }

    // Update is called once per frame
    void Update()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

        RaycastHit hit;
        if (!Physics.Raycast(ray, out hit, 1000, layerMask))
            spriteRenderer.enabled = false;
        else {
            transform.position = new Vector3(hit.point.x, transform.position.y, hit.point.z);
            spriteRenderer.enabled = true;
        }
    }
}

Поскольку курсор — это спрайт (двумерный рисунок), то для его отрисовки Unity использует компонент SpriteRenderer. Мы получаем ссылку на этот компонент в методе Start, чтобы иметь возможность включать/выключать его по мере необходимости.

Также в методе Start мы преобразуем имя слоя “Ground”, который мы создали ранее, в битовую маску. Unity использует битовые операции для фильтрации объектов при поиске пересечений и метод LayerMask.GetMask возвращает битовую маску, соответствующую указанному слою.

В методе Update мы получаем доступ к главной камере сцены с помощью Camera.main и просим ее пересчитать двумерные координаты мыши (полученные с помощью Input.mousePosition) в трехмерный луч. Далее, мы передаем этот луч в метод Physics.Raycast и проверяем, пересекся ли он с каким-то объектом в сцене. Значение 1000 — это максимальное расстояние. В математике лучи бесконечны, а вычислительные ресурсы и память у компьютера — нет. Поэтому Unity просит нас определить какое-то разумное максимальное расстояние.

Если пересечения не было, то мы выключаем SpriteRenderer и изображение курсора пропадает с экрана. Если же пересечение было найдено, то мы перемещаем курсор в точку пересечения. Обратите внимание, что мы не меняем координату Y, потому что точка пересечения луча с землей будет иметь Y равный нулю и присвоив ее нашему курсору мы опять получим эффект Z-fighting, от которого мы пытались избавиться выше. Поэтому, мы берем от точки пересечения только координаты X и Z, а Y оставляем прежний.

Добавляем компонент Cursor к игровому объекту Cursor.

Теперь, доработаем скрипт Player: во-первых, добавим поле Cursor cursor. Затем в методе Start добавим следующие строчки:

cursor = FindObjectOfType<Cursor>();
navMeshAgent.updateRotation = false;

И, наконец, чтобы игрок всегда поворачивался в сторону курсора, в методе Update добавим:

Vector3 forward = cursor.transform.position - transform.position;
transform.rotation = Quaternion.LookRotation(new Vector3(forward.x, 0, forward.z));

Здесь мы также не берем в расчет координату Y.

Стреляй, чтобы выжить

Один только факт поворота в сторону курсора не защитит нас от зомби, а лишь избавит персонаж игрока от эффекта неожиданности — теперь к нему нельзя подкрасться сзади. Чтобы он действительно мог выжить в суровых реалиях нашей игры, нужно научить его стрелять. А что это за выстрел, если его не видно? Всем известно, что любой уважающий стрелок всегда стреляет трассирующими пулями.

Создадим игровой объект Shot и добавим в него стандартный компонент LineRenderer. С помощью поля “Width” в редакторе зададим ему небольшую ширину, например 0.04. Как мы видим, Unity рисует его ярким фиолетовым цветом — таким образом подсвечиваются объекты, не имеющие материала.

Материалы — это важный элемент любого трехмерного движка. С помощью материалов описывается внешний вид объекта. Все параметры освещения, текстуры, шейдеры — все это описывается материалом.

Создадим в проекте директорию Materials и внутри нее — материал, назовем его Yellow. В качестве шейдера выберем Unlit/Color. Этот стандартный шейдер не учитывает освещение, поэтому нашу пулю будет видно даже в темноте. Выберем желтый цвет:

Теперь, когда материал создан, можно назначить его LineRenderer’у:

Создадим скрипт Shot:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Shot : MonoBehaviour
{
    LineRenderer lineRenderer;
    bool visible;

    // Start is called before the first frame update
    void Start()
    {
        lineRenderer = GetComponent<LineRenderer>();
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        if (visible)
            visible = false;
        else
            gameObject.SetActive(false);
    }

    public void Show(Vector3 from, Vector3 to)
    {
        lineRenderer.SetPositions(new Vector3[]{ from, to });
        visible = true;
        gameObject.SetActive(true);
    }
}

Этот скрипт, как вы, наверное, уже догадались, нужно добавить к игровому объекту Shot.

Здесь я использовал небольшой трюк, чтобы с минимумом кода отображать выстрел на экране в течение строго одного кадра. Во-первых, я использую FixedUpdate вместо Update. Метод FixedUpdate вызывается с заданной периодичностью (по умолчанию — 60 кадров в секунду), даже если реальная частота кадров непостоянна. Во-вторых, я завел переменную visible, которую я ставлю в true, когда отображаю выстрел на экране. В следующем FixedUpdate я сбрасываю ее в false, и только в следующем кадре выключаю игровой объект выстрела. По сути, я использую логическую переменную как счетчик от 1 до 0.

Метод gameObject.SetActive включает или выключает весь игровой объект, на котором находится наш компонент. Выключенные игровые объекты не рисуются на экране и у их компонентов не вызываются методы Update, FixedUpdate и т.п. Использование этого метода позволяет делать выстрел невидимым, когда игрок не стреляет.

Также в скрипте есть публичный метод Show, который мы будем использовать в скрипте Player, чтобы фактически отображать пулю при выстреле.

Но сначала нужно иметь возможность получить координаты дула пистолета, чтобы выстрел происходил из правильного отверстия. Для этого найдем в 3d-модели игрока объект Bip001⇨Bip001 Pelvis⇨Bip001 Spine⇨Bip001 R Clavicle⇨Bip001 R UpperArm⇨Bip001 R Forearm⇨Bip001 R Hand⇨R_hand_container⇨w_handgun и добавим в него дочерний объект GunBarrel. Разместим его так, чтобы он находился прямо возле дула пистолета:

Теперь в скрипте Player добавим поля:

Shot shot;
public Transform gunBarrel;

В метод Start скрипта Player добавим:

shot = FindObjectOfType<Shot>();

И в метод Update:

if (Input.GetMouseButtonDown(0)) {
    var from = gunBarrel.position;
    var target = cursor.transform.position;
    var to = new Vector3(target.x, from.y, target.z);
    shot.Show(from, to);
}

Как вы можете догадаться, добавленное публичное поле gunBarrel также, как и moveSpeed ранее, будет доступно в Инспекторе. Давайте назначим ему реальный игровой объект, который мы создали:

Если теперь запустить игру, то мы наконец-то сможем стрелять по зомби!

Что-то тут не так! Кажется, выстрелы не убивают зомби, а просто пролетают сквозь него!

Ну конечно, если посмотреть на наш код выстрела, то мы никаким образом не отслеживаем, попал ли наш выстрел во врага или нет. Просто рисуем линию до курсора.

Это довольно легко исправить. В коде обработки клика мышкой в классе Player после строки var to = … и перед строкой shot.Show(...) нужно добавить следующие строчки:

var direction = (to - from).normalized;

RaycastHit hit;
if (Physics.Raycast(from, to - from, out hit, 100))
    to = new Vector3(hit.point.x, from.y, hit.point.z);
else
    to = from + direction * 100;

Здесь мы используем уже знакомый вам Physics.Raycast, чтобы выпустить луч из дула пистолета и определить, пересекся ли он с каким-либо игровым объектом.

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

Коллайдер — это компонент, с помощью которого физический движок определяет столкновения между объектами. Обычно в качестве коллайдеров используются простые геометрические фигуры — кубы, сферы и т.п. Хотя такой подход дает меньшую точность столкновений, формулы пересечений между такими объектами довольно просты и не требуют больших вычислительных ресурсов. Конечно, если вам нужна максимальная точность, всегда можно пожертвовать производительностью и использовать MeshCollider. Но нам высокая точность не нужна, поэтому воспользуемся компонентом CapsuleCollider:

Теперь пуля не будет пролетать сквозь зомби. Однако зомби все еще бессмертен.

Зомби — зомбячья смерть!

Давайте сначала добавим в Animation Controller зомби анимацию смерти. Для этого перетащим в него анимацию AssetPacks⇨ToonyTinyPeople⇨TT_demo⇨animation⇨zombie⇨Z_death_A. Чтобы активировать ее, создадим новый параметр died с типом trigger. В отличие от других параметров (bool, float и т.п.), триггеры не запоминают свое состояние и больше похожи на вызов функции: активировали триггер — сработал переход, а триггер сбросился обратно. А поскольку умереть зомби может в любом состоянии — и если стоит на месте, и если бежит, то переход будем добавлять из состояния Any State:

Добавим в скрипт Zombie следующие поля:

CapsuleCollider capsuleCollider;
Animator animator;
MovementAnimator movementAnimator;
bool dead;

В метод Start класса Zombie вставляем:

capsuleCollider = GetComponent<CapsuleCollider>();
animator = GetComponentInChildren<Animator>();
movementAnimator = GetComponent<MovementAnimator>();

В самое начало метода Update нужно добавить проверку:

if (dead)
    return;

Ну и, наконец, добавим классу Zombie публичный метод Kill:

public void Kill()
{
    if (!dead) {
        dead = true;
        Destroy(capsuleCollider);
        Destroy(movementAnimator);
        Destroy(navMeshAgent);
        animator.SetTrigger("died");
    }
}

Назначение новых полей, думаю, достаточно очевидно. А что касается метода Kill — в нем мы (если мы еще не мертвы) ставим флаг смерти зомби и удаляем из нашего игрового объекта компоненты CapsuleCollider, MovementAnimator и NavMeshAgent, после чего активируем воспроизведение анимации смерти у контроллера анимаций.

Зачем удалять компоненты? Чтобы как только зомби умирает, он переставал перемещаться по карте и более не был препятствием для пуль. По хорошему, надо еще каким-то красивым образом избавляться от тела после того, как анимация смерти отыграна. Иначе мертвые зомби продолжат отъедать ресурсы и, когда трупов станет слишком много, игра начнет заметно подтормаживать. Самый простой способ — добавить сюда же вызов Destroy(gameObject, 3). Это приведет к тому, что Unity удалит этот игровой объект через 3 секунды после этого вызова.

Чтобы все это наконец заработало, остался последний штрих. В класс Player, в метод Update, там где мы вызываем Physics.Raycast, в ветку для случая, когда было найдено пересечение, добавляем проверку:

if (hit.transform != null) {
    var zombie = hit.transform.GetComponent<Zombie>();
    if (zombie != null)
        zombie.Kill();
}

В переменную hit вызов Physics.Raycast записывает информацию о пересечении. В частности, в поле transform будет ссылка на компонент Transform игрового объекта, с которым пересекся луч. Если у этого игрового объекта есть компонент Zombie, значит это зомби и мы его убиваем. Элементарно!

Ну и чтобы смерть врага выглядела эффектно, добавим к зомби простую систему частиц.

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

Добавим к игровому объекту Zombie систему частиц (щелкаем на нем правой кнопкой и выбираем Effects⇨Particle System):

Я предлагаю следующие параметры:

Transform:

  • Position: Y 0.5
  • Rotation: X -90

Particle System

  • Duration: 0.2
  • Looping: false
  • Start Lifetime: 0.8
  • Start Size: 0.5
  • Start color: зеленый
  • Gravity Modifier: 1
  • Play on Awake: false
  • Emission:
  • Rate over Time: 100
  • Shape:
  • Radius: 0.25

Должно получиться что-то вроде этого:

Осталось активировать ее в методе Kill класса Zombie:

GetComponentInChildren<ParticleSystem>().Play();

И вот теперь совсем другое дело!

Зомби нападают стаей

На самом деле, сражаться с единственным зомби скучно. Вы его убили и все. Где драма? Где страх умереть молодым? Чтобы создать настоящую атмосферу апокалипсиса и безнадежности, зомби должно быть много.

К счастью, сделать это довольно просто. Как вы могли догадаться, нам потребуется еще один скрипт. Назовем его EnemySpawner и заполним следующим содержимым:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public float Period;
    public GameObject Enemy;
    float TimeUntilNextSpawn;

    // Start is called before the first frame update
    void Start()
    {
        TimeUntilNextSpawn = Random.Range(0, Period);
    }

    // Update is called once per frame
    void Update()
    {
        TimeUntilNextSpawn -= Time.deltaTime;
        if (TimeUntilNextSpawn <= 0.0f) {
            TimeUntilNextSpawn = Period;
            Instantiate(Enemy, transform.position, transform.rotation);
        }
    }
}

С помощью публичного поля Period гейм-дизайнер сможет в Инспекторе задать, как часто нужно создавать нового врага. В поле Enemy мы укажем, какого именно врага нужно создавать (пока у нас враг только один, но в дальнейшем мы можем добавить еще). Ну а дальше все просто — с помощью TimeUntilNextSpawn мы отсчитываем, сколько времени осталось до следующего появления врага и, как только время пришло, добавляем в сцену нового зомби с помощью стандартного метода Instantiate. Ах да, в методе Start мы назначаем полю TimeUntilNextSpawn случайное значение, чтобы если у нас в уровне есть несколько спавнеров с одинаковыми задержками, они не добавляли зомби одновременно.

Остался один вопрос — как задать врага в поле Enemy? Для этого мы воспользуемся таким инструментом Unity, как “префабы” (“Prefabs”). По сути, префаб — это кусочек сцены, сохраненный в отдельный файл. Потом мы можем этот файл вставлять в другие сцены (или в эту же) и нам не нужно собирать его из кусочков каждый раз заново. Собрали мы, допустим, из объектов стен, пола, потолка, окон и двери какой-нибудь красивый домик и сохранили его в префаб. Теперь можно легким движением руки вставлять этот домик в другие карты. При этом, если отредактировать файл префаба (например, добавить к домику заднюю дверь), то объект изменится во всех сценах. Иногда это бывает очень удобно. Также мы можем использовать префабы как шаблоны для Instantiate — и этой возможностью мы воспользуемся прямо сейчас.

Чтобы создать префаб, достаточно просто перетащить игровой объект из окна иерархии в окно проекта, остальное Unity сделает сам. Давайте создадим префаб из зомби, а затем добавим в сцену спавнер врагов:

Я в проекте для разнообразия добавил еще три спавнера (так что, в итоге, у меня их 4). И вот, что получилось:

Вот! Это уже похоже на зомби-апокалипсис!

Заключение

Конечно, это далеко не законченная игра. Мы не рассмотрели множество вопросов, например создание пользовательского интерфейса, звуки, жизни и смерть игрока — все это осталось за рамками этой статьи. Но мне кажется, что эта статья станет достойным введением в Unity для тех, кто еще не знаком с этим инструментом. А может быть, и кто-то опытный сможет почерпнуть из нее какой-нибудь трюк?

В общем, друзья, надеюсь вам понравилась моя статья. Пишите ваши вопросы в комментариях, постараюсь ответить. Исходный код проекта можно скачать на гитхабе: https://github.com/zapolnov/otus_zombies. Вам потребуется Unity 2019.3.0f3 или выше, его можно скачать совершенно бесплатно и без СМС с официального сайта: https://store.unity.com/download.

Ссылки на ассеты, использованные в статье:

Источник


Report Page