Создаём простую 3D-гонку на Unity
Гонки — один из самых популярных и простых в реализации игровых жанров. Рассказываем, как за один день создать простую 3D-гонку на Unity.


vlada_maestro / shutterstock
В этой статье мы создадим простую гонку на Unity, в которой будут:
- управление машиной;
- аварии;
- музыка и звуковые эффекты;
- бесконечная дорога;
- очки;
- меню.
Мы уже несколько раз писали, как реализовать такие вещи, поэтому в этой статье сосредоточимся на том, как использовать Unity для создания самой гонки, не вдаваясь в подробности работы с интерфейсом движка.
Если вы раньше не работали с Unity, рекомендуем ознакомиться с этими статьями:
- Как создать 2D-игру на Unity — введение в Unity.
- Что такое ассеты в Unity — добавление компонентов игры.
- Как создать 2D-шутер в Unity — работа с префабами.
- Меню для игры на Unity — об интерфейсе и работе с файлами.
Финальную версию проекта со всеми ассетами можно найти в этом репозитории на GitHub.
Подготовка к проекту
Для начала нужно создать 3D-проект в Unity и импортировать модели и звуки. Вы можете использовать свои или взять те, что находятся в репозитории.
Музыка и звуки найдены на бесплатных сайтах. Машины скачаны из Asset Store, а всё остальное я смоделировал самостоятельно (да, это всего лишь дорожный блок и монетка, но я старался).
Когда всё будет скачано и добавлено в проект, можно начинать.
Добавление дороги
Создайте пустой объект и назовите его Road — в нём будут размещаться все машины и дорожные блоки.

Добавьте в него первый блок:

У объекта Road координаты должны быть по нулям, а у блока X можно поставить на ноль либо на -24.69 — столько он занимает места. Эта цифра нам понадобится, чтобы добавлять новые блоки.
Теперь для блока нужно подключить коллайдеры Box Collider. Но добавлять их нужно не на сам блок, а на дорожное полотно (Plane) и бордюры (Plane_002).

Для полотна сразу установите тег Road (его нужно создать, нажав на Add Tag). Затем приступайте к бордюрам. Это один объект, поэтому нужно просто добавить два коллайдера:

Для бордюров установите тег Wall (его тоже нужно создать). Теги пригодятся для того, чтобы определять, с чем именно сталкивается машина игрока.
После того как будет добавлен игрок, можно будет приступить к генерации бесконечной дороги.
Добавление игрока
Внутри объекта Road нужно создать ещё один пустой объект и назвать его Player. Внутри него добавьте пустой объект Model и камеру. В Model нужно поместить модель машины, а затем установить камеру сзади модели.

Для объекта Player добавьте компонент Rigidbody и два коллайдера:

Один из коллайдеров нужно установить как триггер — с его помощью будут проверяться столкновения с разными объектами.
Теперь нужно заставить машину двигаться. Для этого создадим скрипт Moving и прикрепим его к объекту Player.
В первую очередь добавим переменные:
public Rigidbody rb;
public GameObject car; //Модель машины
public GameObject brokenPrefab; //Префаб сломанной машины
public GameObject modelHolder; //Объект, в который помещается модель
public Controls control; //Скрипт управления, он будет добавлен позже
private float speed = 0.1f; //Скорость на старте
private float maxSpeed = 0.5f; //Максимальная скорость
private float minSpeed = 0.1f; //Минимальная скорость
private bool isAlive = true; //Жива ли машина. Если да, то она будет двигаться
private bool isKilled = false; //Эта переменная нужна, чтобы триггер сработал только один раз
public List<GameObject> wheels; //Колёса машины
Теперь в Unity нужно заполнить все публичные переменные. Пустыми пока можно оставить Control и Broken Prefab, потому что они ещё не готовы.

Переменная Rb будет добавляться скриптом в методе Start():
void Start()
{
rb = GetComponent<Rigidbody>();
}
Внутри этого скрипта он использоваться не будет, но нужен, чтобы генерировать дорогу.
Вернёмся к движению:
void Update()
{
if(isAlive)
{
float newSpeed = speed; //Скорость движения вперёд
float sideSpeed = 0f; //Скорость движения вбок
if(newSpeed > maxSpeed)
{
newSpeed = maxSpeed; //Проверка на превышение максимальной скорости
}
if(newSpeed < minSpeed)
{
newSpeed = minSpeed; //Проверка на слишком низкую скорость
}
//Изменение положения машины - она двигается вперёд
//Для этого к её положению по оси X прибавляется новая скорость, положение по Y остаётся прежним
//К положение по оси Z прибавляется 0.1f, умноженная на боковую скорость
transform.position = new Vector3(transform.position.x + newSpeed, transform.position.y, transform.position.z + 0.1f * sideSpeed);
if(control != null)
{
control.sideSpeed = 0f; //Сброс боковой скорости
}
if(wheels.Count > 0) //Если есть колёса
{
foreach (var wheel in wheels)
{
wheel.transform.Rotate(-3f, 0f, 0f); //Вращение каждого колеса по оси X
}
}
if(tag == "Car")
{
if(transform.position.y < -50f)
{
Destroy(gameObject); //Если это машина NPC, то она будет удаляться со сцены, если упадёт ниже -50f
}
}
}
}
Можно запустить сцену и проверить, как движется машина. Камеру перед этим лучше сместить вбок, чтобы видеть, вращаются ли колёса.

Теперь нужно написать скрипт, который позволит управлять машиной. Назовём его Controls и добавим несколько переменных:
public float speed = 0f; //Скорость
public float maxSpeed = 0.5f; //Максимальная скорость
public float sideSpeed = 0f; //Боковая скорость
Само управление выглядит так:
void Update()
{
float moveSide = Input.GetAxis("Horizontal"); //Когда игрок будет нажимать на стрелочки влево или вправо, сюда будет добавляться 1f или -1f
float moveForward = Input.GetAxis("Vertical"); //То же самое, но со стрелочками вверх и вниз
if(moveSide != 0)
{
sideSpeed = moveSide * -1f; //Если игрок нажал на стрелочки влево или вправо, задаём боковую скорость
}
if(moveForward != 0)
{
speed += 0.01f * moveForward; //Если игрок нажал вверх или вниз
}
else //Если игрок не нажал ни вверх, ни вниз, то скорость будет постепенно возвращаться к нулю
{
if(speed > 0)
{
speed -= 0.01f;
}
else
{
speed += 0.01f;
}
}
if(speed > maxSpeed)
{
speed = maxSpeed; //Проверка на превышение максимальной скорости
}
}
Ссылку на скрипт нужно добавить в Moving:

Затем нужно добавить в метод Update() скрипта Moving следующий код:
if(control != null) //Если подключён скрипт управления
{
newSpeed += control.speed; //Изменение скорости
sideSpeed = control.sideSpeed; //Изменение направления
}
Его надо вставить сразу после объявления переменных newSpeed и sideSpeed. Теперь машиной можно управлять:

Генерация бесконечной дороги
Чтобы машина не падала в пропасть, нужно добавить бесконечную генерацию дороги.
- Перед машиной будет добавляться столько дорожных блоков, сколько нужно, чтобы не было видно пропасти.
- Когда машина проезжает достаточное расстояние от определённого блока, он удаляется, чтобы освободить оперативную память.
Для этого создадим скрипт RoadBlock и прикрепим его к дорожному блоку. В нём будет всего два метода:
public bool Fetch(float x) //Проверка, проехала ли машина игрока этот блок на достаточное расстояние
{
bool result = false;
if(x > transform.position.x + 100f)
{
result = true; //Если машина проехала на 100f от блока, то возвращается true
}
return result;
}
public void Delete()
{
Destroy(gameObject); //Удаление блока
}
Сохраните блок как префаб. А инстанцироваться новые объекты будут в скрипте Road:
public List<GameObject> blocks; //Коллекция всех дорожных блоков
public GameObject player; //Игрок
public GameObject roadPrefab; //Префаб дорожного блока
public GameObject carPrefab; //Префаб машины NPC
public GameObject coinPrefab; //Префаб монеты
private System.Random rand = new System.Random(); //Генератор случайных чисел
void Update()
{
float x = player.GetComponent<Moving>().rb.position.x; //Получение положения игрока
var last = blocks[blocks.Count - 1]; //Номер дорожного блока, который дальше всех от игрока
if(x > last.transform.position.x - 24.69f * 10f) //Если игрок подъехал к последнему блоку ближе, чем на 10 блоков
{
//Инстанцирование нового блока
var block = Instantiate(roadPrefab, new Vector3(last.transform.position.x + 24.69f, last.transform.position.y, last.transform.position.z), Quaternion.identity);
block.transform.SetParent(gameObject.transform); //Перемещение блока в объект Road
blocks.Add(block); //Добавление блока в коллекцию
}
foreach (GameObject block in blocks)
{
bool fetched = block.GetComponent<RoadBlock>().Fetch(x); //Проверка, проехал ли игрок этот блок
if(fetched) //Если проехал
{
blocks.Remove(block); //Удаление блока из коллекции
block.GetComponent<RoadBlock>().Delete(); //Удаление блока со сцены
}
}
}
Те блоки, которые есть на сцене, нужно поместить в коллекцию Blocks. Также надо сразу заполнить остальные переменные.

Теперь можно проверить, как это выглядит:

Генерация машин
Чтобы генерировать машины, нужно сначала создать префаб. Для этого можно скопировать объект Player, удалив камеру и скрипт Controls. Также лучше использовать другую модель, чтобы игрок не путался.
Затем в скрипте Road после добавления нового дорожного блока нужно добавить вот такой код:
float side = rand.Next(1, 3) == 1 ? -1f : 1f; //Случайное определение стороны появления машины
//Добавление машины на сцену
var car = Instantiate(carPrefab, new Vector3(last.transform.position.x + 24.69f, last.transform.position.y + 0.20f, last.transform.position.z + 1.30f * side), Quaternion.Euler(new Vector3(0f, 90f, 0f)));
car.transform.SetParent(gameObject.transform); //Добавление машины в объект Road
Перед этим не забудьте добавить префаб в переменную carPrefab.

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

Потом создайте скрипт Coin с таким кодом:
int direction = 1; //Направление движения монеты
float high = 1.2f; //Наивысшая точка
float low = 0.7f; //Низшая точка
public GameObject coinSound; //Звук монеты
void Update()
{
transform.Rotate(0f, 1f, 0f); //Монета с каждым кадром будет вращаться
if(direction > 0) //Если направление больше нуля, то монета будет двигаться вверх,
{
if(transform.position.y < high) //пока не достигнет наивысшей точки
{
transform.position = new Vector3(transform.position.x, transform.position.y + 0.01f, transform.position.z);
}
else //После направление изменится
{
direction *= -1;
}
}
else
{
if(transform.position.y > low) //И монета будет двигаться вниз
{
transform.position = new Vector3(transform.position.x, transform.position.y - 0.01f, transform.position.z);
}
else
{
direction *= -1; //А когда достигнет низшей точки, снова начнёт двигаться вверх
}
}
}
public void Delete() //Удаление монеты
{
var sound = Instantiate(coinSound, transform.position, transform.rotation); //Добавление звука монеты
Destroy(sound, 2f); //Уничтожение звука через две секунды
Destroy(gameObject); //Сама монета удалится сразу
}
В качестве префаба звука пока можно использовать пустой объект. А сам код генерации можно добавить туда же, где происходит инстанцирование остальных объектов.
if(rand.Next(0, 100) > 70) //Добавление монеты с вероятностью 30%
{
var coin = Instantiate(coinPrefab, new Vector3(last.transform.position.x + 24.69f, last.transform.position.y + 0.20f, last.transform.position.z + 1.50f * side * -1f), Quaternion.identity);
coin.transform.SetParent(gameObject.transform);
}
Можно проверять:

Получение очков
Чтобы игрок мог получать очки, в скрипт Controls нужно добавить следующие переменные:
public float scores = 0f; //Очки
public float highScore = 0f; //Лучший результат
Добавление очков будет происходить в файле Moving в блоке работы со скриптом Controls:
control.scores += 0.1f; //Добавление очков
Чтобы выводить, сколько очков набрал пользователь, нужно создать элемент Canvas и добавить в него текстовый блок:

Для него создайте скрипт Scores со следующим кодом:
using UnityEngine;
using TMPro;
using System;
public class Scores : MonoBehaviour
{
private TextMeshProUGUI text;
private Controls controls;
public GameObject player;
void Start()
{
text = GetComponent<TextMeshProUGUI>();
controls = player.GetComponent<Controls>();
}
void Update()
{
if(controls != null)
{
text.text = $"Highscore: {Math.Floor(controls.highScore)} | Scores: {Math.Floor(controls.scores)}";
}
}
}
Обратите внимание, что тут используется не стандартный текстовый элемент, а объект из библиотеки TextMeshPro.

Обработка столкновений
Теперь нужно добавить логику столкновений с другими объектами. Для начала создадим префаб сломанной машины:

Это та же модель, но здесь для каждого колеса добавлен коллайдер и Rigidbody. То есть при столкновении у машины будут отлетать колёса.
Затем в файл Moving нужно добавить следующий код:
void OnTriggerEnter(Collider other)
{
if(other.tag == "Car" || other.tag == "Wall") //Если машина игрока столкнулась со стеной или другой машиной
{
isAlive = false; //Игрок больше не жив
if(car != null) //Если есть модель
{
if(!isKilled) //Если триггер ещё не сработал
{
Destroy(car); //Удалить старую модель
//Добавить новую модель
var broken = Instantiate(brokenPrefab, transform.position, Quaternion.Euler(new Vector3(0f, -270f, 0f)));
broken.transform.SetParent(modelHolder.transform);
isKilled = true; //Указать, что триггер сработал
StartCoroutine("Die"); //Запустить процесс умирания
}
}
}
if(other.tag == "Coin") //Если столкновение с монетой
{
if(control != null) //Если столкнулась машина игрока
{
control.scores += 100f; //Добавить 100 очков
other.GetComponent<Coin>().Delete(); //Удалить монету
}
}
}
IEnumerator Die() //Процесс умирания
{
string path = "highscore"; //Путь к файлу, в котором сохраняется высший результат
using(FileStream fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
{
byte [] bytes = new byte[Convert.ToInt32(fs.Length)];
fs.Read(bytes, 0, Convert.ToInt32(fs.Length));
string high = Encoding.UTF8.GetString(bytes);
float highScore = 0f;
try
{
highScore = Convert.ToSingle(high);
}
catch(Exception e)
{
Debug.Log(e.ToString());
}
if(highScore < Math.Floor(control.scores))
{
byte[] newScores = Encoding.UTF8.GetBytes(Math.Floor(control.scores).ToString());
fs.Write(newScores, 0, newScores.Length);
}
}
yield return new WaitForSeconds(2f); //Подождать 2 секунды
SceneManager.LoadScene("Menu"); //Перейти в меню
}
Переход в меню можно пока закомментировать, потому что оно ещё не готово.
Вот что должно получиться:

Работа со звуком
Теперь можно добавить звуки:
- музыку;
- звук мотора;
- звук столкновения с машиной;
- звук столкновения с монетой.
Чтобы добавить музыку, к объекту Player подключите компонент Audio Source:

Укажите файл, громкость и поставьте галочку возле Loop, чтобы мелодия повторялась.
Затем добавим звук мотора. Для этого найдите звук, в котором есть монотонный рёв. Его высота будет меняться программным путём, когда игрок будет ускоряться или замедляться.
Добавить к модели машины Audio Source:

А затем в файле Moving добавьте такой код:
car.GetComponent<AudioSource>().pitch = 2 + newSpeed; //Изменение высоты звука
Звук столкновения нужно добавить в префаб сломанной машины, убрав зацикливание. А звук монеты — в пустой объект, который мы инстанцировали в скрипте монеты при столкновении.
Главное меню
Последний штрих — главное меню. Оно будет находиться в отдельной сцене. Создайте скрипт, который будет называться Menu:
using UnityEngine;
using UnityEngine.SceneManagement;
public class Menu : MonoBehaviour
{
public void Play()
{
SceneManager.LoadScene("Main");
}
public void Exit()
{
Application.Quit();
}
}
Добавьте кнопки и укажите методы, которые будут вызываться при нажатии на них:

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

Итог
Получилась довольно простая игра. Для разработчика это интересный опыт, но игрокам она быстро наскучит. Чтобы этого не произошло, можно добавить несколько простых улучшений:
- накопление денег;
- покупку новых машин;
- более высокие скорость и манёвренность для более дорогих машин;
- смену полос для машин и так далее.
Если же вы хотите делать игры покруче, записывайтесь на наш курс «Профессия Разработчик игр на Unity». На нём вы научитесь работать с шейдерами, графикой высокого разрешения и разными механиками, а также многим другим.