ГОНКА КАК КОНСТРУКТОР. Собери и опубликуй свою игру на Unity
ГОНКА КАК КОНСТРУКТОР. Собери и опубликуй свою игру на Unity

Полная версия

ГОНКА КАК КОНСТРУКТОР. Собери и опубликуй свою игру на Unity

Настройки чтения
Размер шрифта
Высота строк
Поля
На страницу:
2 из 2

Выводы

Игра состоит из шести сцен; переходы между ними — SceneManager.LoadScene("имя").

Все используемые сцены должны быть в списке сборки (File → Build Profiles → Scene List).

GameManager — синглтон: доступен из любого скрипта через GameManager.instance.

Пауза — это Time.timeScale = 0; не забывайте возвращать 1 перед сменой сцены.

Кнопки в этом проекте связаны с логикой через On Click в инспекторе; кодовый способ (AddListener) показан в GameUI.cs — скрипте-окаменелости, который никуда не повешен.

Итог: у игры есть скелет — сцены и переходы, менеджер и пауза. Пора нарастить на него мышцы: в следующей главе мы разберём главный скрипт игры — контроллер машины — и узнаем, как «оживить» кусок пластика с колёсами.

Глава 3. Сердце игры: контроллер машины

Машина в игре не едет — это мир договорился делать вид, что она едет.

Введение

CarController.cs — самый большой и самый важный скрипт проекта: он читает ввод игрока, двигает машину, тратит и пополняет топливо, считает дистанцию и управляет звуком двигателя. Сто тридцать строк — и в них вся суть жанра. В этой главе мы разберём его целиком, по кусочкам. Заодно познакомимся с тремя китами Unity-скриптинга: жизненным циклом Start/Update, физикой Rigidbody и системой ввода.

3.1 Поля: состояние машины

public class CarController : MonoBehaviour


{


// Компонент Rigidbody для моделирования физики машины


private Rigidbody rb;



// Скорость движения вперед


public float forwardSpeed = 10.0f;



// Ограничение движения машины по оси Z


private float zLimitMin = 1.25f;


private float zLimitMax = 4.75f;



// Количество бензина в автомобиле


private float fuelAmount = 40f;



// Объекты для вывода топлива и дистанции на экран


public TMPro.TextMeshProUGUI fuelText;


public TMPro.TextMeshProUGUI distanceText;



private Vector3 startPosition;


private float distanceTraveled = 0f;


private bool isMoving = false;


private bool isGameOver = false;


private AudioSource carSound;



Рис. 3.1. «Паспорт» машины игрока в инспекторе: Box Collider, Rigidbody (Collision Detection — Continuous Dynamic) и Car Controller с полями скорости и ссылками на HUD.

Обратите внимание на разницу между public и private. Публичные поля Unity показывает в инспекторе — их можно крутить, не трогая код: скорость forwardSpeed и ссылки на текстовые поля HUD назначаются прямо в сцене. Приватные — внутреннее состояние: запас топлива, пройденный путь, флаги.

Здесь же — первая особенность проекта, о которой стоит сказать честно. Машина в Pursuit едет вдоль оси X мира, а ось Z используется для перестроений поперёк дороги — «полосы» лежат между zLimitMin и zLimitMax. Привычнее было бы ехать вдоль Z («вперёд» в терминах Unity), но авторы выбрали X — и весь остальной код (дорога, препятствия, канистры) следует этому соглашению. И ещё одна оговорка: размеченных «полос» в игре на самом деле нет — есть непрерывный коридор между zLimitMin и zLimitMax, просто игрок читает его как полосы. Мы тоже будем говорить «полоса» для краткости. Для нас важно одно: во всём проекте «вперёд» — это +X, «поперёк» — Z. Запомните, дальше это встретится не раз.

3.2 Start: подготовка

void Start()


{


rb = GetComponent();


fuelText.text = "Fuel: " + fuelAmount;


distanceText.text = "Distance: " + distanceTraveled;


startPosition = transform.position;



// Получение ссылки на компонент AudioSource


carSound = GameObject.Find("CarSound").GetComponent();


}

Start() вызывается один раз, когда объект появляется в сцене, — идеальное место для подготовки. Здесь скрипт берёт ссылку на собственный Rigidbody (компонент физики — о нём ниже), выводит стартовые значения на экран и находит в сцене объект CarSound со звуком двигателя.

Важно: GameObject.Find("CarSound") ищет объект по имени во всей сцене. Если объекта нет или он переименован — вернётся null, и первое же обращение к carSound уронит скрипт с ошибкой NullReferenceException. Такие ошибки — главный источник загадочных поломок в Unity-проектах; научиться читать их в консоли важнее, чем выучить любой API.

3.3 FixedUpdate: движение

Основная логика живёт не в Update(), а в FixedUpdate():

void FixedUpdate()


{


// Получение значений осей ввода


float horizontalAxis = Input.GetAxis("Horizontal");


float verticalAxis = Input.GetAxis("Vertical");



// Расчёт движения машины


Vector3 movement = new Vector3(verticalAxis, 0.0f, -horizontalAxis)


* forwardSpeed * Time.deltaTime;



// Ограничение движения по оси Z (полосы дороги)


float newPosZ = Mathf.Clamp(transform.position.z + movement.z,


zLimitMin, zLimitMax);


movement.z = newPosZ - transform.position.z;



// Определение наличия движения


isMoving = (movement != Vector3.zero);



// Применение движения к Rigidbody машины


rb.MovePosition(rb.position + transform.TransformDirection(movement));

Разберём по строкам.

Почему FixedUpdate? Update() вызывается каждый кадр — то 30, то 144 раза в секунду, как повезёт. Физика же в Unity считается с фиксированным шагом (по умолчанию 50 раз в секунду), и всё, что двигает Rigidbody, положено делать в FixedUpdate() — иначе движение станет дёрганым и непредсказуемым.

Оси ввода. Input.GetAxis("Vertical") возвращает число от до : стрелка вверх или W — плюс, вниз или S — минус. То же с "Horizontal" для стрелок влево/вправо. Ввод плавный: при нажатии значение нарастает постепенно, поэтому машина трогается и останавливается мягко, без рывка.

Сборка вектора. Вертикальная ось идёт в компоненту X (вперёд/назад — помните наше соглашение?), горизонтальная — со знаком минус в Z (перестроение). Умножение на forwardSpeed задаёт темп, а на Time.deltaTime — делает движение независимым от частоты кадров: «десять метров в секунду», а не «десять метров за кадр».

Клетка для машины. Mathf.Clamp(значение, мин, макс) зажимает координату Z в пределах дороги: как бы игрок ни давил на стрелку, машина не вылетит на обочину. Приём «посчитай желаемую позицию — зажми — вычти обратно» встречается в играх постоянно.

Движение через физику. rb.MovePosition(...) — это просьба к физическому движку: «передвинь тело сюда, учитывая столкновения». Если на пути стоит препятствие с коллайдером, физика это заметит — в отличие от прямой записи в transform.position, которая просто телепортирует объект сквозь стены.

3.4 Топливо: расход и канистры

// Проверка на столкновение с канистрой бензина


Collider[] fuelCans = Physics.OverlapSphere(transform.position, 1.0f);


foreach (Collider fuelCan in fuelCans)


{


if (fuelCan.CompareTag("FuelCan"))


{


fuelAmount += 10f;


fuelText.text = "Fuel: " + fuelAmount;


Destroy(fuelCan.gameObject);


}


}



// Уменьшение количества бензина только при наличии движения


if (isMoving)


{


fuelAmount -= Time.deltaTime;


fuelText.text = "Fuel: " + Mathf.Round(fuelAmount).ToString();


}

Сбор канистр устроен через Physics.OverlapSphere: вокруг машины мысленно надувается сфера радиусом один метр, и метод возвращает все коллайдеры, попавшие внутрь. Дальше — проверка тега: у префаба канистры в инспекторе выставлен тег FuelCan, и только такие объекты дают +10 топлива и исчезают (Destroy).

Расход устроен ещё проще: пока машина движется, топливо убывает на Time.deltaTime — то есть ровно на единицу в секунду. Стоишь — не тратишь. Отсюда простая математика баланса: стартовых 40 единиц хватает на 40 секунд езды, каждая канистра дарит ещё 10.

Совет: теги назначаются в инспекторе (выпадающий список Tag над именем объекта) и сравниваются методом CompareTag — он быстрее и безопаснее, чем сравнение строк fuelCan.tag == "FuelCan".

3.5 Конец заезда и счёт

// Проверка на окончание бензина


if ((fuelAmount <= 0 || isGameOver) && !isGameOver)


{


isGameOver = true;


carSound.Stop();



if (fuelAmount <= 0)


{


SceneManager.LoadScene("GameOver");


}


}



// Увеличение пройденного расстояния


if (isMoving)


{


Vector3 currentPos = transform.position;


float dx = currentPos.x - startPosition.x;


if (dx >= 0)


{


distanceTraveled += dx;


}


startPosition = currentPos;


distanceText.text = "Distance: " + Mathf.Round(distanceTraveled).ToString();


}

Присмотритесь к условию (fuelAmount <= 0 || isGameOver) && !isGameOver: оно избыточно — вторая половина всегда отсекает случай isGameOver, так что добавка || isGameOver в первой не делает ничего. Работает? Да. Нужно? Нет. Это ровно тот тип мест, о которых мы говорили во введении: «так написано — чем грозит — как улучшить». Здесь не грозит ничем, просто читается тяжелее, чем могло бы, — смело упрощайте до if (fuelAmount <= 0 && !isGameOver).

Дистанция считается «шагами»: каждый кадр скрипт смотрит, насколько машина продвинулась по X с прошлого кадра (dx), и прибавляет к счётчику — но только если шаг положительный. Ехать назад, чтобы «намотать» счёт, не выйдет.

А как результат попадает на экран поражения, если это другая сцена? Через PlayerPrefs — встроенное хранилище «ключ — значение», которое переживает и смену сцен, и перезапуск игры:

PlayerPrefs.SetFloat("lastPosX", distanceTraveled);

Эта строка стоит в начале FixedUpdate() и сохраняет дистанцию пятьдесят раз в секунду. Работает? Работает. Красиво? Не очень: правильнее сохранять один раз — в момент окончания заезда. PlayerPrefs на некоторых платформах пишет на диск, и дёргать его каждый кадр — расточительно. Возьмите на заметку как первую кандидатуру на рефакторинг.

Звук двигателя устроен незатейливо: если машина движется и звук не играет — включить, если остановилась — выключить. Плюс carSound.Stop() при окончании топлива, чтобы двигатель не «пел» на экране поражения.

Выводы

Во всём проекте «вперёд» — это ось X, «поперёк дороги» — ось Z; полосы ограничены Mathf.Clamp.

Всё, что двигает Rigidbody, живёт в FixedUpdate(); движение умножается на Time.deltaTime.

rb.MovePosition — движение с учётом физики; прямая запись в transform.position игнорирует столкновения.

Сбор предметов: Physics.OverlapSphere + проверка тега CompareTag + Destroy.

Данные между сценами передаются через PlayerPrefs; сохранять их каждый кадр — плохая привычка.

GameObject.Find по имени — хрупкое место: переименование объекта ломает игру в рантайме.

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

Глава 4. Бесконечная дорога

Дорога бесконечна ровно настолько, насколько хватает взгляда. Дальше она не нужна.

Введение

Если бы дизайнер уровня выкладывал трассу вручную, игра закончилась бы там, где у него кончилось терпение. В раннерах поступают иначе: мир генерируется на ходу. Впереди машины дорога достраивается, позади — разбирается, а в памяти в каждый момент живёт лишь небольшое «окно» трассы. В этой главе мы разберём скрипт RoadController.cs, который и показывает этот фокус.

4.1 Стартовый участок и генератор

Загляните в сцену Game: под объектом GameWorld/roads лежит статичный стартовый участок — несколько плиток дороги, травы, деревья и «река» по краям, расставленные вручную. Это декорация первых метров заезда. А рядом висит пустой объект newRoads со скриптом RoadController — он отвечает за всё, что дальше.

public class RoadController : MonoBehaviour


{


private Transform playerTransform;


public GameObject roadPrefab;


public float roadLength = 50f;


public float despawnOffset = -30f;


public float spawnOffset = 80f;


public float despawnDistance = 100f;


private List roads = new List();



private void Start()


{


playerTransform = GameObject.FindGameObjectWithTag("Player").transform;


Spawn();


}

Знакомая картина: публичные поля-настройки, ссылка на игрока через поиск по тегу, список созданных секций. Обратите внимание на важную деталь Unity: значения в инспекторе побеждают значения в коде. В скрипте написано roadLength = 50, но в сцене у компонента выставлено 14 — работать будет именно 14. Дефолты в коде — лишь начальная подсказка; правда всегда в инспекторе. Забудете об этом — будете полчаса гадать, почему правка числа в коде «не работает».

4.2 Spawn: дорога из ниоткуда

private void Spawn()


{


GameObject gameObject = Instantiate(roadPrefab);


gameObject.transform.position = roads.Count == 0


? Vector3.zero


: roads[roads.Count - 1].transform.position + Vector3.right * roadLength;


roads.Add(gameObject);


}

Instantiate(префаб) — главный глагол процедурной генерации: создать копию префаба в сцене. Первая секция встаёт в ноль, каждая следующая — вплотную к предыдущей, со сдвигом на roadLength по X (Vector3.right — это (1, 0, 0); помните соглашение «вперёд = X»?). Секция — это целый префаб с полотном, обочинами и деревьями, так что одна строчка Instantiate выкладывает сразу десяток метров мира.

4.3 Update: конвейер секций

private void Update()


{


// Создаём новые дороги впереди


if (playerTransform.position.x >


roads[roads.Count - 1].transform.position.x - spawnOffset)


{


Spawn();


}



// Удаляем дороги, оставшиеся позади


for (int i = 0; i < roads.Count; i++)


{


if (playerTransform.position.x - roads[i].transform.position.x


> despawnDistance)


{


Destroy(roads[i]);


roads.RemoveAt(i);


i--;


}


}


}

Логика двухтактная, как конвейер.

Такт первый — достроить. Как только игрок приблизился к последней секции ближе, чем на spawnOffset, впереди появляется новая. Игрок никогда не видит «край мира»: дорога всегда успевает построиться за горизонтом.

Такт второй — разобрать. Секции, отставшие от игрока больше, чем на despawnDistance, уничтожаются. Здесь есть тонкость, ради которой стоит остановиться: удаление из списка во время обхода. Когда мы делаем RemoveAt(i), все следующие элементы сдвигаются на одну позицию влево — и если просто продолжить цикл, один элемент окажется пропущенным. Строчка i-- компенсирует сдвиг. Это классическая ловушка работы со списками в любом языке, не только в C#.

Важно: без «разборки» игра рано или поздно захлебнётся: каждая брошенная секция — это десятки объектов, которые Unity продолжает обсчитывать и рисовать. Правило раннера: всё, что игрок больше не увидит, должно быть уничтожено.

Честная оговорка: листинг выше слегка причёсан. В настоящем RoadController.cs циклов уборки два — второй сравнивает отставание с полем despawnOffset (в коде даже отрицательным: -30). Это наследие ранней версии: в сцене у despawnOffset выставлено 30, и фактически секции убирает именно второй цикл, а первый (с порогом despawnDistance = 56) почти никогда не успевает сработать. Игре это не вредит — дорога убирается, память не течёт, — но два цикла, делающих одну работу, это классический кандидат на рефакторинг; мы вспомним о нём в девятой главе.

4.4 Настраиваем конвейер под себя

Поиграйте с параметрами в инспекторе объекта newRoads — это лучший способ почувствовать механику:

roadLength — длина секции. Должна совпадать с реальной длиной префаба, иначе в дороге появятся щели или нахлёсты.

spawnOffset — насколько заранее достраивать. Слишком мало — игрок на большой скорости увидит, как мир «рождается»; слишком много — лишние объекты в памяти.

despawnDistance — как далеко позади разбирать. Уменьшите до 20 — и, оглянувшись (сместив камеру), увидите, как мир исчезает за спиной.

Задание: временно поставьте spawnOffset = 5 и проедьте вперёд на максимальной скорости. Вы увидите «стройку мира» своими глазами. Верните значение на место — и запомните этот кадр: так выглядит внутренность любого раннера, от Subway Surfers до Temple Run.

4.5 Камера: хвост за машиной

Раз уж мы говорим о том, как мир движется вокруг игрока, — два слова о камере. За ней отвечает CameraController.cs:

public class CameraController : MonoBehaviour


{


public Vector3 offset;


public float smoothSpeed = 0.125f;



void FixedUpdate()


{


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


if (player != null)


{


Transform target = player.transform;


Vector3 desiredPosition = target.position + offset;


Vector3 smoothedPosition = Vector3.Lerp(transform.position,


desiredPosition, smoothSpeed);


transform.position = smoothedPosition;



transform.LookAt(target);


}


}


}

Идея проста: желаемая позиция — это позиция машины плюс смещение offset (в сцене: чуть позади и выше), а Vector3.Lerp каждый такт передвигает камеру на долю smoothSpeed пути к цели: чем меньше значение, тем ленивее «хвост». LookAt доворачивает камеру на машину. И вновь правило инспектора: в коде smoothSpeed = 0.125, а в сцене выставлено 0.9 — камера держится за машиной почти жёстко. Хотите мягкую «резинку» — опустите значение до 0.1–0.2 и почувствуйте разницу.

Важно: а вот GameObject.FindGameObjectWithTag каждый физический такт — расточительность: поиск по всей сцене пятьдесят раз в секунду ради объекта, который никуда не девается. Правильный ход — найти игрока один раз в Start() и запомнить ссылку, как делает RoadController. Работает и так — сцена маленькая, — но привычку стоит перенять правильную.

Выводы

Бесконечный мир — это иллюзия: живёт только «окно» вокруг игрока, дальше — генерация и уборка.

Instantiate(префаб) создаёт секции на ходу; позиция каждой считается от предыдущей.

Значения в инспекторе всегда побеждают дефолты в коде.

При удалении из списка в цикле не забывайте компенсировать сдвиг индексов (i--).

Всё, что осталось позади игрока, обязано быть уничтожено — иначе память и производительность утекут.

Камера — это Lerp к позиции машины; ссылку на игрока ищите один раз в Start(), а не каждый такт.

Итог: дорога строится и разбирается сама. Но пустая трасса — это не гонка, а медитация. В следующей главе мы выпустим на дорогу трафик: случайные машины, честные столкновения и эффектные взрывы.

Глава 5. Трафик: препятствия и столкновения

Гонка без риска — это просто дорога на дачу.

Введение

Пустая трасса не создаёт игры. Игру создаёт конфликт: грузовики в вашем ряду, узкие просветы, цена ошибки. В этой главе мы разберём три скрипта, которые вместе отвечают за трафик: ObstacleManager расставляет машины, ObstacleMovement их двигает, а скрипт из файла Collision.cs превращает касание в аварию — со взрывом и экраном поражения.

5.1 ObstacleManager: спавн по прогрессу

public class ObstacleManager : MonoBehaviour


{


public GameObject[] obstaclePrefabs;


public float obstacleSpeed = 5f;


public float spawnDistance = 40f;


public float spawnOffset = 30f;


public float despawnDistance = 80f;



private GameObject player;


private float lastPlayerPosition;


private float currentDistance;



void Update()


{


currentDistance = player.transform.position.x - lastPlayerPosition;



if (currentDistance >= spawnDistance)


{


lastPlayerPosition = player.transform.position.x;



for (float spawnPosX = lastPlayerPosition + spawnOffset;


spawnPosX <= lastPlayerPosition + currentDistance;


spawnPosX += spawnDistance)


{


SpawnObstacle(new Vector3(spawnPosX, 1.4f, 1.7f));


}



DestroyObstacles(player.transform.position.x - despawnDistance);


}


}

Прежде чем разбирать логику — знакомая по четвёртой главе оговорка: значения в инспекторе побеждают значения в коде. В листинге spawnDistance = 40, а в сцене Game у объекта Obstacle выставлено 35 (а ещё spawnOffset 25 вместо 30 и despawnDistance 35 вместо 80) — работают именно сценовые числа, их мы и возьмём в расчёты баланса. И вторая деталь: рядом в сцене лежит выключенный

Конец ознакомительного фрагмента.

Текст предоставлен ООО «Литрес».

Прочитайте эту книгу целиком, купив полную легальную версию на Литрес.

Безопасно оплатить книгу можно банковской картой Visa, MasterCard, Maestro, со счета мобильного телефона, с платежного терминала, в салоне МТС или Связной, через PayPal, WebMoney, Яндекс.Деньги, QIWI Кошелек, бонусными картами или другим удобным Вам способом.

Конец ознакомительного фрагмента
Купить и скачать всю книгу
На страницу:
2 из 2