Когда мы начали разработку батлрояля на 100 игроков, то решили сделать самую большую карту в нашем шутере — 2 на 2 километра против стандартных 200 x 200 метров. Но для таких масштабов нужны были более быстрые способы перемещения, чем просто пешком. Так появилась задача добавить транспорт, которого раньше в проекте не было.

В статье расскажу, как мы добавили автомобили в мобильный PvP-шутер на Unity: разберу префаб транспорта, синхронизацию игроков и поделюсь небольшими лайфхаками.

Изначально транспорт ввели для королевской битвы, а чуть позже использовали его во фриплее (аналоге GTA Online) и в мини-игре «Гонки». Для батлрояля установка была такая: игроки находятся в равных условиях, а встроенные покупки не ломают баланс. Поэтому машины могут выглядеть совершенно по-разному, но у всех одинаковое поведение, а, следовательно — реализация. 

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

Реализацию можно разделить на три части: 

  • Визуальная отвечает за отображение модели, за расположение мест водителей, пассажиров и прочие внешние эффекты.

  • Функциональная управляет движением и всей физикой транспорта.

  • Сетевая отвечает за синхронизацию транспорта по сети.

Пройдемся и по каждой.

Визуальная часть

Префаб и скины

Вне зависимости от визуалки, любой транспорт под «капотом» имеет общий физический движок, коллайдер и 4 колеса. 

Префаб функциональной части автомобиля
Префаб функциональной части автомобиля

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

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

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

Тень автомобиля сделана через простой stencil-шейдер на специальном меше:

Звук

Из интересного — транспорт имитирует работу коробки передач для более естественной озвучки автомобиля. Шум двигателя постепенно усиливается до переключения на следующую передачу со специальным звуком.

    private int CurrentGearUpdate(float CurrentSpeed)
    {
        if (engineVolume < 0.99)
        {
            engineVolume += Time.deltaTime;
        }

        gearSoundStopTimer -= Time.deltaTime;
        var gearStep = maxSpeed / gears;
        var currentGear = Mathf.Clamp(Mathf.FloorToInt((CurrentSpeed) / gearStep), 0, gears);

        if (lastGear != currentGear)
        {
            lastGear = currentGear;

            if (gearSoundStopTimer < 0)
            {
                engineVolume = 0.3f;
                gearSoundStopTimer = 5f;

                freeAudioSource.enabled = true;
                freeAudioSource.PlayOneShot(gearClip, 0.3f);
            }
        }
        return currentGear;
    }

Функциональная часть

Посадка в транспорт

В технике есть место для водителя и трех пассажиров. Тут была проблема — если несколько игроков одновременно будут садиться в машину, то все могут попасть на водительское место. Поэтому игрок не делает этого сразу, а отправляет RPC о посадке через сервер, выбрав тип отправки PhotonTargets.AllViaServer. Так мы исключили ситуацию, когда два игрока сядут на одно место — его займет тот, от кого первым пришло RPC на сервер.

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

Защита от переворачивания

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

Защита от застревания

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

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

Код проверки:

private void CheckImpulse()
    {    
        if (carRigidbody.velocity.sqrMagnitude < 0.5f) {
            timeStuck += Time.deltaTime;
        } else {
            timeStuck = 0f;
        }
        if (timeStuck > 0.5f) {
            carRigidbody.velocity = ThisTransform.forward * 3f * vertical;
            timeStuck = 0f;
        }
    }

Управление и контролы

Управлять транспортом можно одним из двух способов на выбор: 

1. Джойстик.

2. Педали. 

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

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

Сетевая часть

Одна из главных задач наших задач — правильная синхронизация транспорта по сети. Мы используем Photon Cloud (о нем рассказывали здесь), где логика находится на клиенте. Поэтому первым делом необходимо было определиться, кто из клиентов будет отвечать за синхронизацию позиции и состояния.

Батлрояль

Все машины в батлрояле сразу находятся на карте. Они являются «объектами сцены» — то есть сетевыми объектами, которые не принадлежат никому, но имеют один и тот же сетевой ID у всех клиентов. Вариант, где за это отвечает мастер-клиент не подходил, так как, во-первых, от качества его соединения будет зависеть пользовательский опыт других игроков. А во-вторых, у него самого будет очень большая нагрузка на просчет всех машин в игре.

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

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

Гонки

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

Еще нужно было решить задачу с определением того, сколько игрок проехал, кто из игроков впереди, а кто сзади. Для этого расставили вдоль трассы чекпойнты и указали стартовый. От него в начале игры рассчитывается общая длина трассы и расстояние каждого чекпоинта:

PlayerCheckpoint checkPnt = startCheckpoint;
float distance = 0;

while (checkPnt != null) 
{
  checkPnt.distance = distance;

  if (checkPnt.next != null) 
  {
    distance += (checkPnt.next.transform.position - checkPnt.transform.position).magnitude;

    if (!checkPnt.next.isStartPoint)
    {
      checkPnt = checkPnt.next;
    }
    else
    {
      lapDistance = distance;
      checkPnt = null;
    }
  } 
  else 
  {
    checkPnt = null;
  }
}

Игроку остается узнать, какую точку он проехал и определить свою дистанцию между ней и следующей точкой:

Vector3 direction = (currentCheckpoint.next.transform.position - currentCheckpoint.transform.position).normalized;
Quaternion rot = Quaternion.LookRotation (new Vector3(direction.x, 0, direction.z));

Vector3 posDeltaPlayerRelative = Quaternion.Inverse(rot) * (myPlayer.transform.position - currentCheckpoint.transform.position);
Vector3 posDeltaNextRelative = Quaternion.Inverse(rot) * (currentCheckpoint.next.transform.position - currentCheckpoint.transform.position);

float currentDistance = currentCheckpoint.distance + ((currentCheckpoint.next.startPoint ? lapDistance : currentCheckpoint.next.distance) - currentCheckpoint.distance) * Mathf.Clamp01(posDeltaPlayerRelative.z / posDeltaNextRelative.z);

Фриплей

Здесь транспорт работает, как в батлрояле. Разница лишь в том, что он не расставлен заранее на карте в определенных местах, а спавнится по кнопке — игрок нажимает на нее и рядом создается машина.

И немного об актуальном для всех режимов.

Игрок синхронизирует только то, в какой машине и на каком месте он сидит, затем просто отображается на этом месте. Так мы избегаем ситуаций, когда игроки могут выпадать из транспорта и «плыть» по воздуху рядом.

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

Вместо заключения

Благодаря модульной системе, мы собираем новые виды транспорта как конструктор. Уже, например, подключили новый модуль с физическим поведением — получили вертолет; изменили габариты физической начинки, ее настройки, а также сделали два посадочных места — получили квадроцикл.

Комментарии (0)