C# для разработки игр в Unity

Курс предназначен для начинающих разработчиков и охватывает ключевые аспекты программирования на C# внутри движка Unity. Вы изучите синтаксис языка, работу с API Unity, физику и основы архитектуры игровых скриптов.

Введение в C# и структура скриптов в Unity

Введение в C# и структура скриптов в Unity

Добро пожаловать в курс «C# для разработки игр в Unity». Это первая статья, и она станет фундаментом для всего вашего дальнейшего пути в создании игр. Мы не просто будем учить язык программирования; мы будем учиться «думать» на языке игрового движка.

Многие новички считают, что создание игр — это магия. На самом деле, это логика, упакованная в структуру. Unity предоставляет вам тело игры (графику, физику, звук), а C# — это её мозг. Без скриптов ваши персонажи будут просто статичными манекенами, а мир — безжизненной декорацией.

В этой статье мы разберем, как C# интегрирован в Unity, из чего состоит стандартный скрипт и как работает жизненный цикл игрового объекта.

Почему именно C#?

Unity использует C# (читается как «си-шарп») в качестве основного языка сценариев. Это объектно-ориентированный язык программирования, разработанный компанией Microsoft. Для нас, как для разработчиков игр, важны следующие его преимущества:

  • Строгая типизация: Язык помогает избегать глупых ошибок, требуя четкого определения типов данных.
  • Управление памятью: C# берет на себя большую часть работы по очистке оперативной памяти (Garbage Collection), позволяя вам сосредоточиться на геймплее.
  • Огромное сообщество: Если вы столкнулись с проблемой, скорее всего, кто-то уже решил её и выложил решение в интернет.

Архитектура Unity: GameObject и Компоненты

Прежде чем писать код, нужно понять философию Unity. Вся сцена в Unity состоит из объектов, называемых GameObjects. Сам по себе GameObject — это просто пустой контейнер, точка в пространстве. Чтобы он стал камерой, светом, персонажем или стеной, к нему нужно добавить Компоненты.

Скрипт на C#, который вы пишете, — это тоже компонент. Когда вы создаете скрипт и «вешаете» его на объект, вы создаете новый тип поведения для этого объекта.

Создание первого скрипта

В интерфейсе Unity создание скрипта происходит через окно Project. Вы нажимаете правую кнопку мыши, выбираете Create -> C# Script и даете ему имя.

Важное правило: Имя файла скрипта должно в точности совпадать с именем класса внутри этого файла. Если вы назвали файл PlayerController, то и класс внутри должен называться PlayerController. Если они отличаются, Unity не сможет подключить этот скрипт к объекту.

Давайте рассмотрим структуру стандартного скрипта, который Unity генерирует автоматически.

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

public class MyFirstScript : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {

    }
}

Разберем этот код построчно. Понимание каждой строки критически важно для дальнейшей работы.

1. Пространства имен (Namespaces)

В самом верху файла мы видим строки, начинающиеся с ключевого слова using:

using UnityEngine;

Это подключение библиотек. Представьте, что вы столяр. Чтобы работать, вам нужен ящик с инструментами. using UnityEngine; говорит программе: «Открой ящик с инструментами Unity». Это дает нам доступ к таким классам, как GameObject, Transform, Debug и многим другим. Без этой строки компьютер не поймет команды, специфичные для движка.

2. Объявление класса

public class MyFirstScript : MonoBehaviour
  • public: Означает, что этот класс доступен для других частей программы (и для редактора Unity).
  • class MyFirstScript: Мы объявляем чертеж (класс) с именем MyFirstScript.
  • : MonoBehaviour: Это самая важная часть. Двоеточие означает наследование. Мы говорим, что наш скрипт — это не просто какой-то код, а наследник MonoBehaviour. Все скрипты, которые должны прикрепляться к объектам в Unity, обязаны наследоваться от MonoBehaviour. Именно это превращает обычный класс C# в компонент Unity.

3. Тело класса

Всё, что находится между фигурными скобками { и }, является телом класса. Здесь живут переменные (данные) и методы (действия).

Методы жизненного цикла: Start и Update

Unity работает по принципу игрового цикла (Game Loop). В отличие от обычных программ, которые выполняют задачу и закрываются, игра работает в бесконечном цикле, отрисовывая кадры десятки раз в секунду. MonoBehaviour предоставляет нам специальные методы, которые Unity вызывает автоматически в определенные моменты времени.

Метод Start()

void Start()
{
    // Код инициализации
}

Метод Start вызывается один раз в момент, когда объект с этим скриптом становится активным в сцене, но перед тем, как будет отрисован первый кадр.

Для чего используется:

  • Настройка начального количества жизней.
  • Получение ссылок на другие компоненты.
  • Установка начальной позиции.

Метод Update()

void Update()
{
    // Код, выполняемый каждый кадр
}

Метод Update вызывается каждый кадр. Если ваша игра работает с частотой 60 кадров в секунду (60 FPS), то код внутри Update выполнится 60 раз за одну секунду.

Для чего используется:

  • Считывание ввода игрока (нажатие клавиш).
  • Перемещение персонажа.
  • Таймеры и кулдауны.

Важно понимать: время между вызовами Update не является константой. Оно зависит от того, насколько быстро компьютер может обработать текущий кадр. Это время называется DeltaTime.

Если мы хотим выразить зависимость частоты кадров математически, мы можем использовать простую формулу:

FPS=1dtFPS = \frac{1}{dt}

где FPSFPS — количество кадров в секунду (Frames Per Second), а dtdt — время, затраченное на обработку одного кадра (Delta Time). Чем меньше времени тратится на кадр, тем выше FPS.

Консоль и отладка

Как узнать, что ваш код работает, если визуально ничего не происходит? Для этого используется Консоль. В C# для Unity есть команда, которая позволяет выводить текстовые сообщения в окно Console редактора.

Debug.Log("Привет, Unity!");

Давайте напишем наш первый рабочий код. Изменим стандартный шаблон следующим образом:

using UnityEngine;

public class HelloWorld : MonoBehaviour
{
    void Start()
    {
        Debug.Log("Я родился! Этот код сработал один раз.");
    }

    void Update()
    {
        // Будьте осторожны с логами в Update, они могут заполнить консоль мгновенно!
        // Debug.Log("Я работаю!");
    }
}

Если вы создадите такой скрипт, прикрепите его к любому объекту на сцене и нажмете кнопку Play, в консоли появится сообщение «Я родился! Этот код сработал один раз.».

Синтаксис: на что обратить внимание

C# — язык строгий к пунктуации. Новички часто совершают одни и те же ошибки:

  1. Точка с запятой (;): Каждая команда должна заканчиваться точкой с запятой. Это как точка в конце предложения в русском языке.
    • Правильно: Debug.Log("Test");
    • Ошибка: Debug.Log("Test")
  2. Фигурные скобки {}: Они обозначают блоки кода. У каждого открывающего { должен быть закрывающий }. Если вы потеряете скобку, скрипт не скомпилируется.
  3. Регистр букв: C# чувствителен к регистру. Start, start и START — это три разных слова для компьютера. Методы Unity всегда пишутся с большой буквы (Start, Update), а ключевые слова языка — с маленькой (void, class, public).

Заключение

Мы рассмотрели структуру базового скрипта в Unity. Теперь вы знаете, что скрипт — это компонент, наследуемый от MonoBehaviour, который имеет доступ к инструментам UnityEngine. Вы также узнали разницу между Start (инициализация) и Update (цикл) и научились выводить сообщения в консоль.

В следующих статьях мы начнем наполнять эти методы реальной логикой: научимся создавать переменные, управлять ими и заставим объекты двигаться.

Жизненный цикл MonoBehaviour и манипуляция объектами GameObject

Жизненный цикл MonoBehaviour и манипуляция объектами GameObject

В предыдущей статье мы познакомились с основами скриптов в Unity, узнали, что такое MonoBehaviour, и написали свой первый код, выводящий сообщение в консоль. Мы выяснили, что методы Start и Update вызываются движком автоматически. Но это лишь верхушка айсберга.

Чтобы создавать сложные игровые механики — от стрельбы и передвижения до спавна врагов и управления инвентарем — нам нужно глубже понять, когда именно Unity вызывает методы и как мы можем управлять объектами на сцене через код. Сегодня мы превратим наши скрипты из пассивных наблюдателей в активных кукловодов.

Расширенный жизненный цикл скрипта

Жизнь скрипта не ограничивается методами Start и Update. Unity предоставляет целую цепочку событий, которые происходят в строго определенном порядке. Понимание этого порядка (Execution Order) критически важно, чтобы избежать ошибок, когда один скрипт пытается обратиться к данным другого, который еще не успел инициализироваться.

Этап инициализации: Awake, OnEnable, Start

Часто новички пишут весь код настройки в Start. Однако иногда переменные нужно настроить до того, как игра начнется. Для этого существуют Awake и OnEnable.

  1. Awake(): Вызывается самым первым, как только объект создан или сцена загружена. Этот метод срабатывает, даже если сам скрипт (компонент) выключен (галочка снята), но объект активен.
    • Использование: Инициализация собственных переменных, настройка ссылок на компоненты внутри того же объекта (Singleton паттерн часто живет здесь).
  2. OnEnable(): Вызывается каждый раз, когда объект или скрипт становится активным.
    • Использование: Подписка на события (Events), сброс параметров при повторном включении врага.
  3. Start(): Вызывается перед первым кадром, если скрипт активен.
    • Использование: Получение ссылок на другие объекты, сложная логика, требующая, чтобы все остальные объекты уже прошли стадию Awake.

Золотое правило инициализации: Настраивайте себя в Awake, а связи с другими — в Start. Это гарантирует, что когда вы обратитесь к соседу в Start, он уже будет готов (так как его Awake точно прошел).

Этап обновлений: Update, FixedUpdate, LateUpdate

Мы уже знаем про Update, который зависит от частоты кадров (FPS). Но для физики и камеры нужны другие подходы.

  1. FixedUpdate(): Вызывается с фиксированным интервалом времени (по умолчанию 0.02 секунды, то есть 50 раз в секунду). Этот интервал не зависит от FPS.
    • Использование: Любые манипуляции с физикой (Rigidbody). Если двигать физическое тело в обычном Update, оно будет дергаться или проваливаться сквозь стены при лагах.
  2. Update(): Вызывается каждый кадр.
    • Использование: Ввод игрока (Input), таймеры, простая логика перемещения без физики.
  3. LateUpdate(): Вызывается каждый кадр, но строго после того, как отработали все Update во всех скриптах.
    • Использование: Камера, следующая за игроком. Мы должны быть уверены, что игрок уже закончил движение в Update, прежде чем двигать камеру к его новой позиции, иначе камера будет дрожать.

Этап завершения: OnDisable, OnDestroy

Когда объект уходит со сцены, нам нужно прибраться за собой.

  1. OnDisable(): Вызывается, когда объект выключается.
    • Использование: Отписка от событий, остановка звуков.
  2. OnDestroy(): Вызывается перед полным удалением объекта или при выгрузке сцены.
    • Использование: Очистка памяти, сохранение данных перед смертью персонажа.

Доступ к компонентам: GetComponent

GameObject — это контейнер. Чтобы скрипт мог управлять, например, физикой объекта, ему нужно получить доступ к компоненту Rigidbody, который лежит в том же контейнере.

Для этого используется метод GetComponent<Тип>().

public class Jumper : MonoBehaviour
{
    private Rigidbody rb;

    void Start()
    {
        // Мы просим Unity найти компонент Rigidbody на ЭТОМ же объекте
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // Теперь мы можем использовать rb для добавления силы
            rb.AddForce(Vector3.up * 500);
        }
    }
}

Важно о производительности: Метод GetComponent — «дорогой» для процессора. Unity приходится перебирать список компонентов, чтобы найти нужный. Никогда не вызывайте GetComponent внутри методов Update или FixedUpdate. Лучшая практика — найти компонент один раз в Start или Awake, сохранить его в переменную (как в примере выше) и использовать эту переменную. Этот процесс называется кэшированием компонентов.

Манипуляция трансформацией (Transform)

Каждый GameObject обязательно имеет компонент Transform. Он отвечает за позицию, поворот и масштаб. Поскольку он есть всегда, Unity предоставляет к нему быстрый доступ через свойство transform (с маленькой буквы).

Позиция и Векторы

Позиция в 3D-пространстве описывается с помощью структуры Vector3, которая содержит три координаты: xx, yy, zz.

Чтобы переместить объект, мы можем изменить его координаты. Допустим, мы хотим двигать объект вперед.

void Update()
{
    // Движение вперед со скоростью 5 метров в секунду
    transform.Translate(Vector3.forward * 5f * Time.deltaTime);
}

Здесь мы видим использование Time.deltaTime. Давайте разберем математику этого процесса. Если мы просто будем прибавлять значение к позиции каждый кадр, скорость объекта будет зависеть от FPS. На мощном компьютере (120 FPS) объект улетит в два раза дальше, чем на слабом (60 FPS).

Формула корректного перемещения выглядит так:

P⃗new=P⃗old+V⃗⋅Δt\vec{P}_{new} = \vec{P}_{old} + \vec{V} \cdot \Delta t

Где P⃗new\vec{P}_{new} — новая позиция объекта, P⃗old\vec{P}_{old} — текущая позиция, V⃗\vec{V} — вектор скорости (направление * величина), а Δt\Delta t — время, прошедшее с последнего кадра (Time.deltaTime).

Умножая на Time.deltaTime, мы переходим от единиц «метров на кадр» к единицам «метров в секунду». Это делает движение плавным и независимым от мощности компьютера.

Вращение и Масштаб

  • Вращение (Rotation): Хранится в кватернионах (Quaternion), сложной математической структуре, избегающей проблем с углами Эйлера. Однако для простых операций мы можем использовать методы transform.Rotate(Vector3) или обращаться к углам Эйлера через transform.eulerAngles.
  • Масштаб (Scale): Доступен через transform.localScale. Изменение этого вектора позволяет сплющивать или растягивать объекты.

Управление объектами: Создание и Уничтожение

В играх объекты постоянно появляются (пули, враги, монеты) и исчезают. Unity предоставляет для этого два мощных метода.

Instantiate (Создание)

Метод Instantiate создает копию любого объекта. Чаще всего мы копируем Префабы (заранее настроенные шаблоны объектов).

public GameObject bulletPrefab; // Сюда мы перетащим префаб пули в редакторе
public Transform firePoint;     // Точка, откуда вылетит пуля

void Shoot()
{
    // Создаем пулю в позиции firePoint с поворотом firePoint
    Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);
}

Destroy (Уничтожение)

Метод Destroy удаляет объект из сцены и очищает память.

// Удалить этот игровой объект мгновенно
Destroy(gameObject);

// Удалить этот объект через 3 секунды (например, для взрыва)
Destroy(gameObject, 3f);

// Удалить только компонент (скрипт), но оставить объект
Destroy(this);

Обратите внимание: gameObject (с маленькой буквы) — это ссылка на сам объект, к которому прикреплен скрипт. this — это ссылка на текущий экземпляр класса скрипта (компонент).

Активация и Деактивация

Иногда объект не нужно удалять, а нужно просто скрыть (например, меню паузы или инвентарь). Для этого используется метод SetActive.

// Скрыть объект (он перестанет отрисовываться, и Update перестанет работать)
gameObject.SetActive(false);

// Показать объект
gameObject.SetActive(true);

Проверить, активен ли объект в данный момент, можно через свойство activeSelf (локальная активность) или activeInHierarchy (активность с учетом родителей).

Поиск объектов (и почему этого стоит избегать)

Иногда скрипту нужно найти другой объект на сцене, не имея прямой ссылки. Для этого существует GameObject.Find.

GameObject player = GameObject.Find("Player");

Этот метод ищет объект по имени. Также есть FindWithTag, который ищет по тегу.

Предостережение: Операция поиска по строке очень медленная. Unity перебирает все объекты в сцене, сравнивая их имена. Если вы будете делать это в Update (каждый кадр), ваша игра начнет тормозить. Используйте поиск только в крайних случаях и только в методах инициализации (Start/Awake), сохраняя результат в переменную.

Практический пример: Собираемый предмет

Давайте объединим полученные знания и напишем скрипт для монетки, которая вращается, а при сборе исчезает.

using UnityEngine;

public class Coin : MonoBehaviour
{
    public float rotationSpeed = 100f;

    void Update()
    {
        // 1. Вращение монетки вокруг своей оси Y
        // Vector3.up — это сокращение для new Vector3(0, 1, 0)
        transform.Rotate(Vector3.up * rotationSpeed * Time.deltaTime);
    }

    // Этот метод вызывается, когда в триггер входит другой коллайдер
    void OnTriggerEnter(Collider other)
    {
        // 2. Проверяем, что вошел именно Игрок
        if (other.CompareTag("Player"))
        {
            Debug.Log("Монетка собрана!");

            // 3. Уничтожаем объект монетки
            Destroy(gameObject);
        }
    }
}

В этом небольшом скрипте мы использовали:

  • Update для анимации вращения.
  • Vector3 и Time.deltaTime для плавности.
  • Событие физики OnTriggerEnter (о нем подробнее в следующих статьях).
  • Destroy для удаления объекта.

Заключение

Теперь вы понимаете, как бьется сердце скрипта Unity. Вы знаете, что Awake готовит объект, Start налаживает связи, Update обрабатывает логику, а FixedUpdate считает физику. Вы научились манипулировать пространством через Transform, создавать новые миры через Instantiate и разрушать их через Destroy.

Эти инструменты — база для любого взаимодействия в игре. В следующей статье мы углубимся в тему, которую затронули лишь вскользь — Физика и столкновения, чтобы наши объекты обрели вес и плотность.

Работа с физикой, коллизиями и системой ввода Input System

Работа с физикой, коллизиями и системой ввода Input System

В предыдущих статьях мы научились создавать скрипты и перемещать объекты, изменяя их координаты через компонент Transform. Это отличный способ для движения платформ или камер, но когда дело доходит до персонажей, мячей или падающих ящиков, прямое изменение координат выглядит неестественно. Объекты проходят сквозь стены, не имеют инерции и не падают вниз.

Чтобы оживить мир, нам нужна физика. Unity использует мощный физический движок (PhysX для 3D и Box2D для 2D), который берет на себя сложные математические расчеты столкновений и гравитации. В этой статье мы разберем три кита интерактивности: Rigidbody (физическое тело), Colliders (формы столкновений) и Input (управление).

Rigidbody: Добавляем вес и гравитацию

Чтобы объект начал подчиняться законам физики Ньютона, к нему нужно добавить компонент Rigidbody (Твердое тело). Как только вы это сделаете, объект обретет массу и начнет падать под действием гравитации.

Основные свойства Rigidbody

  • Mass (Масса): Вес объекта в килограммах. Тяжелые объекты сложнее сдвинуть с места, но падают они с той же скоростью, что и легкие (если не учитывать сопротивление воздуха).
  • Drag (Сопротивление): Сопротивление воздуха. Чем выше значение, тем быстрее объект замедляется при движении.
  • Use Gravity: Если галочка стоит, объект будет падать.
  • Is Kinematic: Очень важная настройка. Если включить эту опцию, объект перестанет реагировать на физические силы и гравитацию, но сам сможет толкать другие объекты. Это полезно для движущихся платформ: они должны быть твердыми, но не должны падать.

Управление через код

Управлять физическим объектом через transform.position — плохая идея. Это телепортация, которая ломает физические расчеты. Вместо этого мы должны прикладывать к объекту силы.

Вспомним второй закон Ньютона:

F⃗=m⋅a⃗\vec{F} = m \cdot \vec{a}

где F⃗\vec{F} — вектор силы, действующей на тело, mm — масса тела, а a⃗\vec{a} — вектор ускорения, которое получает тело. Чтобы изменить скорость объекта, мы должны приложить к нему силу.

public class PhysicsJump : MonoBehaviour
{
    public float jumpForce = 500f;
    private Rigidbody rb;

    void Start()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        // Пример неправильного использования физики (в Update)
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // AddForce добавляет импульс к текущей скорости
            rb.AddForce(Vector3.up * jumpForce);
        }
    }
}

Важное напоминание: Постоянные физические силы (например, двигатель машины) нужно применять в методе FixedUpdate, так как физический движок работает с фиксированным тактом времени. Однократные импульсы (прыжок) допустимо вызывать в Update при нажатии кнопки.

Коллизии и Триггеры

Чтобы объекты не проваливались сквозь пол, им нужны Коллайдеры (Colliders). Это невидимые сетки, определяющие физическую форму объекта. Самые простые и эффективные коллайдеры — примитивы: BoxCollider (куб), SphereCollider (сфера), CapsuleCollider (капсула).

Два режима работы коллайдера

  1. Collision (Столкновение): Стандартный режим. Объекты ударяются, отскакивают, трутся друг о друга. Используется для стен, пола, твердых предметов.
  2. Trigger (Триггер): Если в настройках коллайдера поставить галочку Is Trigger, он станет проницаемым. Объекты будут проходить сквозь него, но Unity сообщит нам об этом событии. Используется для зон сбора монет, автоматических дверей, чекпоинтов.

Обработка событий в коде

Unity вызывает специальные методы, когда происходит взаимодействие коллайдеров. Для их работы хотя бы один из объектов должен иметь компонент Rigidbody.

Для твердых столкновений (Collision):
void OnCollisionEnter(Collision collision)
{
    // Срабатывает в момент удара
    Debug.Log("Ударились об: " + collision.gameObject.name);
}

void OnCollisionStay(Collision collision)
{
    // Срабатывает каждый кадр, пока объекты соприкасаются
}

void OnCollisionExit(Collision collision)
{
    // Срабатывает, когда контакт прекратился
}
Для триггеров (Trigger):
void OnTriggerEnter(Collider other)
{
    // other — это коллайдер, который вошел в нашу зону
    if (other.CompareTag("Player"))
    {
        Debug.Log("Игрок вошел в зону!");
    }
}

Обратите внимание на разницу в аргументах: OnCollisionEnter получает объект типа Collision (содержит информацию о точке удара, силе), а OnTriggerEnter получает просто Collider (ссылку на того, кто вошел).

Система ввода (Input System)

Чтобы игрок мог взаимодействовать с физикой, нам нужно считывать нажатия клавиш, движения мыши или геймпада. В Unity существуют две системы ввода: классический Input Manager и новый пакет Input System. В рамках базового курса мы сосредоточимся на классическом классе Input, так как он проще для понимания логики C#.

Считывание клавиатуры

Есть три состояния нажатия кнопки:

  1. Input.GetKeyDown(KeyCode.Space) — возвращает true только в первый кадр нажатия. Идеально для прыжка, выстрела, открытия меню.
  2. Input.GetKey(KeyCode.Space) — возвращает true все время, пока кнопка удерживается. Подходит для автоматической стрельбы или спринта.
  3. Input.GetKeyUp(KeyCode.Space) — срабатывает один раз при отпускании кнопки.

Оси управления (Axes)

Вместо того чтобы проверять конкретные клавиши (W, A, S, D), лучше использовать Оси. Это абстракция, которая позволяет управлять персонажем и с клавиатуры, и с геймпада без переписывания кода.

Метод Input.GetAxis("Name") возвращает число от -1.0 до 1.0.

  • Horizontal: A / Стрелка влево = -1.0; D / Стрелка вправо = 1.0; Ничего не нажато = 0.
  • Vertical: S / Стрелка вниз = -1.0; W / Стрелка вверх = 1.0.

Особенность GetAxis в том, что значение меняется плавно (симуляция аналогового стика). Если вам нужны мгновенные значения (-1, 0, 1), используйте Input.GetAxisRaw.

Практика: Физическое движение персонажа

Давайте объединим знания и напишем скрипт для движения шара физикой.

using UnityEngine;

public class BallController : MonoBehaviour
{
    public float speed = 10f;
    private Rigidbody rb;

    void Start()
    {
        rb = GetComponent<Rigidbody>();
    }

    void FixedUpdate()
    {
        // 1. Считываем ввод (можно делать и в Update, но применяем силу здесь)
        float moveHorizontal = Input.GetAxis("Horizontal");
        float moveVertical = Input.GetAxis("Vertical");

        // 2. Создаем вектор направления
        // В 3D играх ось Y — это высота, поэтому двигаемся по X и Z
        Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);

        // 3. Прикладываем силу
        rb.AddForce(movement * speed);
    }
}

Raycasting (Бросание лучей)

Еще одна важная часть физики — Рейкастинг. Это способ «посмотреть» или «выстрелить» невидимым лучом из точки А в направлении Б и узнать, во что он попал.

Это используется для стрельбы в шутерах, проверки земли под ногами или взаимодействия с предметами (взгляд на кнопку).

void Update()
{
    // Создаем луч из центра камеры вперед
    Ray ray = new Ray(transform.position, transform.forward);
    RaycastHit hit;

    // Запускаем луч длиной 100 метров
    if (Physics.Raycast(ray, out hit, 100f))
    {
        // Если луч во что-то попал, информация запишется в переменную hit
        Debug.Log("Я вижу: " + hit.collider.name);

        // Можно узнать расстояние до объекта
        float distance = hit.distance;
    }
}

Здесь используется ключевое слово out. Оно означает, что метод Raycast не только возвращает true/false (попал/не попал), но и записывает подробные данные в переменную hit.

Заключение

Физика в Unity — это сочетание компонентов (Rigidbody, Collider) и кода (AddForce, OnCollisionEnter). Использование физики делает игровой процесс непредсказуемым и живым, но требует аккуратности: не забывайте использовать FixedUpdate для постоянных сил и оптимизировать коллайдеры (использовать простые формы вместо сложных мешей).

В следующей статье мы отойдем от механик и займемся визуальной частью и интерфейсом — созданием UI, чтобы игрок мог видеть счет, здоровье и меню.

Игровая логика: префабы, корутины и создание пользовательского интерфейса

Игровая логика: префабы, корутины и создание пользовательского интерфейса

Мы уже прошли большой путь: разобрались со структурой скриптов, научились управлять объектами и даже внедрили физику. Но пока наши «игры» больше похожи на физические песочницы. В них нет структуры, нет таймеров, нет возможности проиграть или выиграть, и, самое главное, игрок не видит никакой информации о состоянии игры, кроме того, что происходит на сцене.

В этой статье мы превратим набор механик в полноценный игровой цикл. Мы разберем три фундаментальные концепции:

  1. Префабы (Prefabs): Как создавать шаблоны объектов для их массового использования.
  2. Корутины (Coroutines): Как выполнять действия с задержкой во времени, не замораживая игру.
  3. UI (User Interface): Как создать интерфейс и связать его с кодом.

Префабы: Клонирование и шаблонизация

Представьте, что вы делаете стратегию, и вам нужно создать 100 одинаковых солдат. Если вы будете создавать каждого солдата вручную на сцене, а потом решите изменить им цвет шлема, вам придется менять его 100 раз. Это неэффективно.

В Unity для этого существуют Префабы. Префаб — это сохраненная копия объекта, хранящаяся в файлах проекта, которая служит шаблоном (чертежом).

Как работает связь Префаб-Экземпляр

Когда вы перетаскиваете объект из окна иерархии (Hierarchy) в окно проекта (Project), он становится префабом (иконка куба становится синей). Теперь вы можете:

  • Перетаскивать этот префаб обратно на сцену сколько угодно раз, создавая Экземпляры (Instances).
  • Изменить настройки самого префаба, и эти изменения мгновенно применятся ко всем его экземплярам на всех сценах.

Спавн префабов через код

В статье про GameObject мы уже касались метода Instantiate. Теперь рассмотрим его в контексте игровой логики. Допустим, мы хотим создать генератор врагов.

using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    // Ссылка на префаб. Мы перетащим сюда файл префаба из окна Project
    public GameObject enemyPrefab;

    // Точка появления
    public Transform spawnPoint;

    void Start()
    {
        SpawnEnemy();
    }

    void SpawnEnemy()
    {
        // Создаем копию префаба в позиции и с поворотом spawnPoint
        Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
    }
}

Главная сила префабов в том, что скрипту EnemySpawner абсолютно всё равно, как выглядит враг. Это может быть зомби, танк или космический корабль. Главное, что у нас есть ссылка на шаблон.

Корутины: Управление временем

До сих пор весь наш код выполнялся мгновенно. Метод Update срабатывает каждый кадр. Но что, если мы хотим сделать паузу? Например: «Подождать 2 секунды перед возрождением игрока» или «Плавно менять цвет в течение 5 секунд».

Если вы попытаетесь использовать стандартные методы C# вроде Thread.Sleep, вы заморозите всю игру. Картинка застынет, потому что Unity выполняет всё в одном потоке.

Для решения этой задачи Unity использует Корутины (Coroutines).

Что такое Корутина?

Корутина — это специальный метод, выполнение которого можно приостановить (поставить на паузу) и продолжить с того же места в следующем кадре или через заданное время.

Синтаксис Корутины

Для создания корутины нужно соблюдать три правила:

  1. Тип возвращаемого значения должен быть IEnumerator (требует подключения using System.Collections;).
  2. В теле метода должна быть хотя бы одна инструкция yield return.
  3. Запускается корутина методом StartCoroutine.

Рассмотрим пример спавнера, который создает врагов каждые 3 секунды.

using System.Collections;
using UnityEngine;

public class TimedSpawner : MonoBehaviour
{
    public GameObject prefab;

    void Start()
    {
        // Запускаем корутину
        StartCoroutine(SpawnRoutine());
    }

    // Обратите внимание на тип IEnumerator
    IEnumerator SpawnRoutine()
    {
        // Бесконечный цикл внутри корутины не зависнет, если есть yield
        while (true)
        {
            Instantiate(prefab, transform.position, Quaternion.identity);

            // Магия здесь: мы говорим Unity "Вернись к этому коду через 3 секунды"
            yield return new WaitForSeconds(3f);
        }
    }
}

Ключевые команды yield

  • yield return null; — подождать до следующего кадра.
  • yield return new WaitForSeconds(float t); — подождать tt секунд (зависит от игрового времени, пауза в игре остановит таймер).
  • yield return new WaitForSecondsRealtime(float t); — подождать tt секунд реального времени (игнорирует паузу).

Математически время задержки можно описать так:

Tend=Tstart+ΔtT_{end} = T_{start} + \Delta t

где TendT_{end} — время продолжения выполнения, TstartT_{start} — время вызова yield, а Δt\Delta t — указанная задержка. Unity автоматически проверяет каждый кадр, наступило ли время TendT_{end}.

Пользовательский интерфейс (UI)

Игра без интерфейса слепа. Игроку нужно знать здоровье, количество патронов и счет. В Unity за отрисовку интерфейса отвечает система UI Canvas.

Canvas (Холст)

Все элементы интерфейса (кнопки, текст, картинки) должны находиться внутри объекта Canvas. Если вы создадите кнопку (Create -> UI -> Button), Unity автоматически создаст Canvas, если его еще нет.

Canvas — это прозрачная пленка, натянутая поверх камеры. В отличие от игровых объектов, которые живут в метрах, элементы UI живут в пикселях экрана (или относительных единицах).

RectTransform

Вместо обычного Transform, элементы UI используют RectTransform. Это более сложный компонент, который позволяет настраивать:

  • Anchors (Якоря): К какой части экрана «приклеен» элемент (например, всегда в правом верхнем углу, независимо от разрешения экрана).
  • Pivot (Опорная точка): Точка, вокруг которой происходит вращение и масштабирование элемента.

Взаимодействие кода и UI

Чтобы управлять текстом или кнопками из скрипта, нам нужно подключить пространство имен UnityEngine.UI.

Примечание: В современных версиях Unity часто используется пакет TextMeshPro (TMP) вместо стандартного Text, так как он обеспечивает четкость шрифтов при любом масштабе. Однако логика работы через код у них очень похожа. В примерах ниже мы будем использовать стандартный UI для простоты.

Давайте создадим скрипт, который считает клики по кнопке и выводит их на экран.

  1. Создайте в Unity Canvas.
  2. Внутри Canvas создайте Text (назовите его ScoreText) и Button.
  3. Создайте скрипт ClickerGame и повесьте его на любой объект (обычно создают пустой объект GameManager).
using UnityEngine;
using UnityEngine.UI; // Обязательно для работы с UI!

public class ClickerGame : MonoBehaviour
{
    public Text scoreText;  // Сюда перетащим объект Text
    private int score = 0;

    void Start()
    {
        UpdateScoreUI();
    }

    // Этот метод должен быть public, чтобы кнопка его увидела
    public void AddScore()
    {
        score++;
        UpdateScoreUI();
    }

    void UpdateScoreUI()
    {
        // Свойство .text принимает только строковые значения (string)
        scoreText.text = "Счет: " + score.ToString();
    }
}

Подключение кнопки (События)

Написание метода AddScore недостаточно. Кнопка на экране не знает, что она должна вызывать этот метод.

Чтобы связать их:

  1. Выберите объект Button в иерархии.
  2. Найдите компонент Button в инспекторе.
  3. Внизу есть список On Click (). Нажмите +.
  4. В поле объекта перетащите тот объект, на котором висит ваш скрипт ClickerGame.
  5. В выпадающем меню выберите ClickerGame -> AddScore.

Теперь при нажатии на кнопку Unity вызовет ваш метод.

Практический пример: Таймер обратного отсчета

Давайте объединим корутины и UI, чтобы создать таймер, который отсчитывает время до конца уровня.

using System.Collections;
using UnityEngine;
using UnityEngine.UI;

public class LevelTimer : MonoBehaviour
{
    public Text timerText;
    public int timeLimit = 60;

    void Start()
    {
        StartCoroutine(TimerRoutine());
    }

    IEnumerator TimerRoutine()
    {
        int timeLeft = timeLimit;

        while (timeLeft > 0)
        {
            // Обновляем текст
            timerText.text = timeLeft.ToString();

            // Ждем 1 секунду
            yield return new WaitForSeconds(1f);

            // Уменьшаем время
            timeLeft--;
        }

        // Цикл закончился, значит время вышло
        timerText.text = "GAME OVER";

        // Здесь можно вызвать логику проигрыша
        Debug.Log("Время вышло!");
    }
}

В этом примере мы видим, как линейная логика корутины (while -> wait -> action) позволяет легко читать код, который выполняется во времени. Если бы мы делали это в Update, нам пришлось бы создавать дополнительные переменные для хранения текущего времени и постоянно вычитать из них Time.deltaTime.

Формула для расчета оставшегося времени в Update выглядела бы так:

Tcurrent=Ttotal−(tnow−tstart)T_{current} = T_{total} - (t_{now} - t_{start})

где TcurrentT_{current} — текущее значение таймера, TtotalT_{total} — начальное время, tnowt_{now} — текущее время системы, а tstartt_{start} — время начала отсчета. Корутины избавляют нас от необходимости вручную считать эту разницу каждый кадр.

Заключение

Сегодня мы сделали огромный шаг от простых скриптов к полноценной архитектуре игры.

  • Префабы позволяют нам наполнять мир контентом, не дублируя работу.
  • Корутины дают контроль над временем и последовательностью событий.
  • UI создает мост между кодом игры и игроком.

Теперь у вас есть все инструменты для создания простого игрового прототипа: вы можете создать персонажа, заставить его собирать предметы (префабы), вести счет (UI) и ограничивать игру по времени (корутины).

В следующей статье мы углубимся в более сложные аспекты программирования: работу с данными, сохранение прогресса и переходы между сценами.

Взаимодействие между скриптами, события и основы оптимизации

Взаимодействие между скриптами, события и основы оптимизации

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

На этапе прототипа это работает отлично. Но по мере роста проекта прямые связи превращают код в «спагетти». Если вы удалите скрипт игрока, сломаются скрипты врагов, интерфейса, звука и камеры.

В этой статье мы научимся писать чистый, модульный код, используя События (Events) и паттерн Синглтон (Singleton). Также мы затронем важную тему Оптимизации, чтобы ваша игра работала плавно даже на слабых устройствах.

Проблема сильной связности (Tight Coupling)

Представьте, что у вас есть скрипт PlayerHealth. Когда здоровье падает до нуля, должно произойти следующее:

  1. Проиграться анимация смерти.
  2. Обновиться UI (показать экран «Game Over»).
  3. Остановиться музыка.
  4. Сохраниться статистика.

При «лобовом» подходе ваш скрипт здоровья будет выглядеть так:

public class PlayerHealth : MonoBehaviour
{
    public UIManager uiManager;
    public AudioManager audioManager;
    public AnimationController animController;
    public SaveSystem saveSystem;

    public void Die()
    {
        uiManager.ShowGameOver();
        audioManager.StopMusic();
        animController.PlayDeathAnim();
        saveSystem.SaveGame();
    }
}

Это сильная связность. Скрипт здоровья знает слишком много о вещах, которые его не касаются. Чтобы добавить новую реакцию на смерть (например, создать эффект взрыва), вам придется лезть в код PlayerHealth и добавлять туда новую переменную.

Решение: События (Events) и Делегаты

Лучший способ развязать этот узел — использовать Паттерн Наблюдатель (Observer Pattern). Представьте радиостанцию. Станция просто вещает сигнал. Ей всё равно, кто её слушает: один человек, миллион или никто.

В C# для этого используются Action из пространства имен System.

Создание события

Перепишем наш пример. Теперь PlayerHealth просто кричит: «Я умер!», а остальные сами решают, что с этим делать.

using UnityEngine;
using System; // Нужно для Action

public class PlayerHealth : MonoBehaviour
{
    // Объявляем событие. Action — это тип делегата, который ничего не возвращает и не принимает аргументов.
    public static event Action OnPlayerDied;

    public void Die()
    {
        // Проверяем, есть ли подписчики (не null), и вызываем событие
        OnPlayerDied?.Invoke();
    }
}

Подписка на событие

Теперь пойдем в скрипт UIManager и научим его слушать это радио.

public class UIManager : MonoBehaviour
{
    // Подписываемся при включении объекта
    void OnEnable()
    {
        // += означает "подписаться"
        PlayerHealth.OnPlayerDied += ShowGameOver;
    }

    // Отписываемся при выключении
    void OnDisable()
    {
        // -= означает "отписаться"
        PlayerHealth.OnPlayerDied -= ShowGameOver;
    }

    void ShowGameOver()
    {
        Debug.Log("Показываем экран проигрыша");
    }
}

Важнейшее правило: Всегда отписывайтесь от событий в OnDisable или OnDestroy. Если вы уничтожите объект UIManager, но не отпишете его от события, PlayerHealth попытается вызвать метод у несуществующего объекта, что приведет к ошибкам или утечкам памяти.

Теперь, если вы захотите добавить звук смерти, вы просто создадите скрипт SoundManager и подпишетесь на то же событие PlayerHealth.OnPlayerDied. Сам скрипт игрока менять не придется.

Паттерн Синглтон (Singleton)

Иногда нам нужен объект, который существует в единственном экземпляре и доступен из любой точки кода. Обычно это GameManager, AudioManager или InventorySystem.

Синглтон — это класс, который позволяет обращаться к себе через статическую переменную Instance.

public class GameManager : MonoBehaviour
{
    // Статическая переменная, хранящая ссылку на единственный экземпляр
    public static GameManager Instance;

    public int Score { get; private set; }

    void Awake()
    {
        // Если экземпляра еще нет — это мы
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject); // Не уничтожать при смене сцен
        }
        else
        {
            // Если экземпляр уже есть, а мы попытались создать второй — уничтожаем самозванца
            Destroy(gameObject);
        }
    }

    public void AddScore(int amount)
    {
        Score += amount;
    }
}

Теперь из любого скрипта (например, из монетки) вы можете написать:

GameManager.Instance.AddScore(10);

Вам не нужно искать этот объект через Find или перетаскивать ссылки в инспекторе.

Основы оптимизации

Unity делает много работы за вас, но она не может исправить плохую архитектуру. Оптимизация — это искусство делать так, чтобы игра тратила меньше ресурсов процессора (CPU) и оперативной памяти (RAM).

1. Кэширование компонентов

Мы уже упоминали это, но повторим. Метод GetComponent требует ресурсов. Метод Find требует огромных ресурсов.

Плохо (каждый кадр ищем компонент):

void Update()
{
    GetComponent<Rigidbody>().AddForce(Vector3.up);
}

Хорошо (нашли один раз и запомнили):

Rigidbody rb;
void Start()
{
    rb = GetComponent<Rigidbody>();
}
void Update()
{
    rb.AddForce(Vector3.up);
}

Если представить это математически, то стоимость операции поиска в цикле можно описать формулой:

Ctotal=N⋅CsearchC_{total} = N \cdot C_{search}

где CtotalC_{total} — общая нагрузка на процессор за секунду, NN — количество кадров (FPS), а CsearchC_{search} — стоимость одной операции поиска компонента. При 60 FPS вы заставляете компьютер выполнять тяжелую работу 60 раз в секунду без причины.

2. Сборщик мусора (Garbage Collection) и строки

C# — язык с автоматическим управлением памятью. Когда вы создаете переменную, память выделяется. Когда переменная больше не нужна, специальная программа (Garbage Collector или GC) очищает память. Работа GC вызывает микро-зависания (фризы).

Самый частый источник мусора — создание строк в Update.

void Update()
{
    // КАЖДЫЙ КАДР создается новая строка, объединяющая "Score: " и число
    scoreText.text = "Score: " + score;
}

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

3. Object Pooling (Пул объектов)

Представьте пулемет. Создавать (Instantiate) и уничтожать (Destroy) пулю 10 раз в секунду — очень дорого для процессора. Это вызывает фрагментацию памяти и нагружает GC.

Вместо этого используется Пул объектов:

  1. В начале игры создаем 50 пуль и выключаем их (SetActive(false)).
  2. При выстреле находим выключенную пулю, ставим её в дуло и включаем (SetActive(true)).
  3. Когда пуля попадает в стену, мы не уничтожаем её, а просто выключаем, возвращая в «бассейн».

Порядок выполнения скриптов (Script Execution Order)

Иногда возникают ошибки, когда ScriptA в методе Start пытается обратиться к ScriptB, но ScriptB еще не успел инициализироваться.

По умолчанию Unity не гарантирует, чей Start выполнится раньше.

Чтобы избежать этого:

  1. Используйте Awake для внутренней инициализации (настройки своих переменных).
  2. Используйте Start для внешней инициализации (обращения к другим скриптам).
  3. В крайнем случае, можно настроить порядок принудительно в Project Settings -> Script Execution Order.

Заключение

Сегодня мы перешли от написания кода к архитектуре.

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

Эти знания отличают новичка от профессионала. В следующем модуле мы займемся сохранением данных, чтобы прогресс игрока не терялся после выхода из игры.