21.10.2026 18 мин чтения Просмотров:0

Клиентское предсказание в Unity: мгновенный отклик в мультиплеере

Разбираем client-side prediction и server reconciliation на C#: фиксированные тики, буферы ввода, откат с повторной симуляцией, сглаживание ошибки и интерполяция чужих игроков.

Клиентское предсказание в Unity: мгновенный отклик в мультиплеере

В этой статье разберём, как сделать так, чтобы персонаж в сетевой игре откликался на нажатие клавиши мгновенно, даже если до сервера 120 мс, и при этом сервер оставался главным. Вы получите рабочую схему клиентского предсказания (client-side prediction) и согласования с сервером (server reconciliation) на C#: тики, кольцевые буферы ввода и состояний, откат и повторную симуляцию, сглаживание ошибки и интерполяцию чужих игроков. Код не привязан к конкретной сетевой библиотеке: его можно положить поверх Netcode for GameObjects, Mirror, FishNet или своего транспорта.

Проблема: авторитарный сервер и задержка

Самая честная схема мультиплеера — авторитарный сервер. Клиент не сообщает «я теперь стою в точке (10, 0, 5)», он сообщает «я нажал W». Сервер применяет ввод, двигает персонажа и рассылает всем результат. Так читеру сложно телепортироваться или бегать вдвое быстрее: сервер просто не примет такое движение.

Но у наивной реализации есть неприятный побочный эффект. Игрок нажимает W, ввод летит на сервер 60 мс, сервер обрабатывает его на ближайшем тике, ответ летит обратно ещё 60 мс. Персонаж начинает двигаться через 120–150 мс после нажатия. Для стратегии это терпимо, для шутера или платформера — нет: управление ощущается «ватным», как будто персонаж в киселе.

Решение известно со времён Quake и подробно описано в статьях Габриэля Гамбетты и в докладах Valve о Source Engine:

  • Предсказание. Клиент не ждёт сервер и сразу применяет свой ввод к своему персонажу по тем же правилам, что и сервер.
  • Согласование. Когда приходит авторитарное состояние от сервера, клиент сравнивает его со своим прошлым предсказанием. Если совпало — всё хорошо. Если нет — клиент откатывается к серверному состоянию и заново проигрывает все вводы, которые сервер ещё не подтвердил.
  • Интерполяция. Чужих персонажей клиент не предсказывает, а показывает с небольшой задержкой, плавно переходя между двумя последними известными состояниями.

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

Что встроено в Unity, а что придётся писать самому

Прежде чем писать своё, полезно понимать, что дают официальные пакеты.

  • Netcode for Entities (стек DOTS) предсказание умеет из коробки: есть предсказываемые «призраки» (predicted ghosts), группа систем PredictedSimulationSystemGroup, буферы ввода по тикам и автоматический откат. Если проект уже на ECS, свою систему писать не нужно.
  • Netcode for GameObjects даёт фиксированный сетевой тик (NetworkManager.NetworkTickSystem, частота задаётся в NetworkConfig.TickRate), RPC и NetworkTransform с интерполяцией. Готового предсказания с откатом для персонажа там нет, это один из самых частых вопросов на форумах Unity. Обычно его пишут поверх тиков и RPC — ровно то, чем мы займёмся.
  • Сторонние библиотеки (FishNet, Photon Fusion, Photon Quantum) предлагают собственные механизмы предсказания. Принципы те же, что ниже, так что статья поможет понимать и их.

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

Шаг 1. Фиксированный тик вместо Update

Предсказание не работает на переменном Time.deltaTime. Если клиент при 144 FPS проинтегрирует движение мелкими шагами, а сервер при 30 тиках — крупными, результаты разойдутся просто из-за арифметики с плавающей точкой и порядка столкновений. Поэтому вся симуляция идёт тиками фиксированной длины, а у каждого тика есть номер.

public sealed class TickClock
{
    public readonly float TickInterval;
    public uint CurrentTick { get; private set; }

    private float _accumulator;

    public TickClock(int tickRate)
    {
        TickInterval = 1f / tickRate;
    }

    // Возвращает, сколько тиков нужно выполнить за этот кадр.
    public int Advance(float deltaTime)
    {
        _accumulator += deltaTime;
        int ticks = 0;

        // Ограничиваем догонялки, чтобы после фриза не выполнить 200 тиков подряд.
        while (_accumulator >= TickInterval && ticks < 8)
        {
            _accumulator -= TickInterval;
            CurrentTick++;
            ticks++;
        }
        return ticks;
    }

    // Доля до следующего тика: пригодится для плавной отрисовки.
    public float Alpha => _accumulator / TickInterval;
}

Это классический аккумулятор из статьи Гленна Фидлера «Fix Your Timestep». Если вы используете Netcode for GameObjects, вместо собственного аккумулятора можно подписаться на событие NetworkManager.NetworkTickSystem.Tick и брать номер из NetworkManager.LocalTime.Tick. Важно одно: на клиенте и на сервере один и тот же интервал тика.

Шаг 2. Ввод и состояние как простые структуры

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

using UnityEngine;

public struct InputPayload
{
    public uint Tick;
    public Vector2 Move;     // нормализованный WASD или стик
    public bool Jump;
}

public struct StatePayload
{
    public uint Tick;
    public Vector3 Position;
    public Vector3 Velocity;
    public bool Grounded;
}

Структуры держим маленькими: ввод уходит на сервер каждый тик, состояние приходит обратно. Для реальной игры Move стоит квантовать в два sbyte, а позицию передавать с ограниченной точностью, но для понимания механики хватит и такого варианта.

Состояние должно содержать всё, что влияет на следующий тик. Если прыжок зависит от таймера coyote time, этот таймер обязан быть в StatePayload, иначе после отката клиент восстановит позицию, но не таймер, и повторная симуляция пойдёт по другой ветке.

Шаг 3. Детерминированная функция симуляции

Это сердце системы. Одна статическая функция: на входе — состояние и ввод, на выходе — новое состояние. Никаких Time.deltaTime, Random без сида, обращений к Input и полей MonoBehaviour, которые меняются где-то ещё.

using UnityEngine;

public static class MovementSimulation
{
    public const float MoveSpeed = 6f;
    public const float Acceleration = 40f;
    public const float JumpSpeed = 7f;
    public const float Gravity = -20f;

    public static StatePayload Step(StatePayload state, InputPayload input,
                                    float dt, CharacterController controller)
    {
        Vector3 wish = new Vector3(input.Move.x, 0f, input.Move.y) * MoveSpeed;

        Vector3 velocity = state.Velocity;
        Vector3 horizontal = new Vector3(velocity.x, 0f, velocity.z);
        horizontal = Vector3.MoveTowards(horizontal, wish, Acceleration * dt);
        velocity.x = horizontal.x;
        velocity.z = horizontal.z;

        if (state.Grounded && input.Jump)
            velocity.y = JumpSpeed;
        else if (!state.Grounded)
            velocity.y += Gravity * dt;
        else
            velocity.y = -1f; // прижимаем к земле, чтобы isGrounded не мигал

        // Ставим контроллер в состояние, из которого считаем шаг.
        controller.enabled = false;
        controller.transform.position = state.Position;
        controller.enabled = true;

        controller.Move(velocity * dt);

        return new StatePayload
        {
            Tick = input.Tick,
            Position = controller.transform.position,
            Velocity = velocity,
            Grounded = controller.isGrounded
        };
    }
}

Почему CharacterController, а не Rigidbody? Потому что CharacterController.Move выполняется синхронно, сразу при вызове, и его можно вызвать десять раз подряд за кадр для повторной симуляции. С Rigidbody так не получится: физика PhysX двигает тела только во время шага физики сцены. Перематывать её можно через Physics.simulationMode = SimulationMode.Script и ручной Physics.Simulate(dt), но тогда при откате придётся пересимулировать всю сцену, а не одного персонажа. Это дорого и сложно, поэтому большинство игр предсказывают кинематического персонажа, а физические объекты вокруг просто интерполируют.

Обратите внимание на выключение контроллера перед телепортом. Пока CharacterController включён, прямое присваивание transform.position может быть перезаписано его внутренним состоянием. Альтернатива — включить Physics.autoSyncTransforms или вызвать Physics.SyncTransforms(), но явное выключение надёжнее и понятнее.

Шаг 4. Кольцевой буфер по номеру тика

Клиенту нужно помнить, какой ввод он отправил и какое состояние предсказал на каждом из последних N тиков. Удобнее всего — массив фиксированного размера, где индекс вычисляется как tick % size. Никаких аллокаций, доступ за O(1).

public sealed class TickBuffer<T> where T : struct
{
    private readonly T[] _items;
    private readonly uint[] _ticks;

    public TickBuffer(int size)
    {
        _items = new T[size];
        _ticks = new uint[size];
        for (int i = 0; i < size; i++)
            _ticks[i] = uint.MaxValue; // «пусто»
    }

    public int Size => _items.Length;

    public void Set(uint tick, T value)
    {
        int index = (int)(tick % (uint)_items.Length);
        _items[index] = value;
        _ticks[index] = tick;
    }

    public bool TryGet(uint tick, out T value)
    {
        int index = (int)(tick % (uint)_items.Length);
        if (_ticks[index] == tick)
        {
            value = _items[index];
            return true;
        }
        value = default;
        return false;
    }
}

Хранить номер тика рядом со значением важно: иначе после переполнения круга буфер вернёт ввод тика 1000, когда вы просили тик 1000 − 1024, и вы об этом не узнаете. Размер выбирайте с запасом: при 60 тиках в секунду буфер на 1024 элемента покрывает больше 17 секунд, а пинг в 300 мс занимает всего 18 тиков.

Шаг 5. Клиент: предсказать, запомнить, отправить

Каждый тик клиент делает три вещи: собирает ввод, сразу применяет его к своему персонажу и отправляет на сервер.

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public sealed class PredictedPlayer : MonoBehaviour
{
    private const int BufferSize = 1024;

    [SerializeField] private Transform _visual; // отдельный дочерний объект с моделью

    private CharacterController _controller;
    private TickClock _clock;
    private readonly TickBuffer<InputPayload> _inputs = new(BufferSize);
    private readonly TickBuffer<StatePayload> _states = new(BufferSize);

    private StatePayload _current;
    private StatePayload _previous;

    private StatePayload _latestServerState;
    private bool _hasNewServerState;

    private bool _jumpQueued;

    public INetworkSender Network { get; set; }

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
        _clock = new TickClock(60);
        _current = new StatePayload { Position = transform.position };
        _previous = _current;
    }

    private void Update()
    {
        // Кнопку ловим в Update, иначе короткое нажатие между тиками потеряется.
        if (UnityEngine.Input.GetButtonDown("Jump"))
            _jumpQueued = true;

        if (_hasNewServerState)
        {
            Reconcile();
            _hasNewServerState = false;
        }

        int ticks = _clock.Advance(Time.deltaTime);
        for (int i = 0; i < ticks; i++)
            RunTick(_clock.CurrentTick);

        RenderInterpolated();
    }

    private void RunTick(uint tick)
    {
        var input = new InputPayload
        {
            Tick = tick,
            Move = new Vector2(UnityEngine.Input.GetAxisRaw("Horizontal"),
                               UnityEngine.Input.GetAxisRaw("Vertical")),
            Jump = _jumpQueued
        };
        _jumpQueued = false;

        _previous = _current;
        _current = MovementSimulation.Step(_current, input, _clock.TickInterval, _controller);

        _inputs.Set(tick, input);
        _states.Set(tick, _current);

        Network.SendInput(input);
    }

    // Вызывается сетевым слоем, когда пришло состояние от сервера.
    public void OnServerState(StatePayload state)
    {
        if (state.Tick <= _latestServerState.Tick && _latestServerState.Tick != 0)
            return; // старый или продублированный пакет
        _latestServerState = state;
        _hasNewServerState = true;
    }
}

Для краткости здесь старый Input Manager. С новой Input System логика та же: читайте значение действия Move в момент тика, а нажатие прыжка накапливайте флагом между тиками.

Модель персонажа вынесена в отдельный дочерний объект _visual. Корневой объект с контроллером прыгает строго по тикам, а модель мы будем двигать плавно. Это пригодится и для интерполяции между тиками, и для сглаживания ошибок после отката.

Интерфейс сети минимальный:

public interface INetworkSender
{
    void SendInput(InputPayload input);
}

В Netcode for GameObjects это будет [Rpc(SendTo.Server)]-метод на NetworkBehaviour, в Mirror — [Command]. На практике ввод отправляют не по одному, а пачкой последних 3–5 тиков в каждом пакете: если один пакет потеряется, следующий привезёт пропущенный ввод. Ввод весит несколько байт, так что избыточность почти бесплатна.

Шаг 6. Сервер: применить ввод и ответить состоянием

Сервер держит свою копию персонажа и очередь вводов от клиента. Каждый тик он берёт ввод с нужным номером, прогоняет через ту же MovementSimulation.Step и отправляет результат.

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public sealed class ServerPlayer : MonoBehaviour
{
    private CharacterController _controller;
    private readonly SortedList<uint, InputPayload> _pending = new();
    private StatePayload _state;
    private InputPayload _lastInput;
    private uint _lastProcessedTick;

    public IServerSender Network { get; set; }

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
        _state = new StatePayload { Position = transform.position };
    }

    public void OnClientInput(InputPayload input)
    {
        if (input.Tick <= _lastProcessedTick)
            return; // дубликат из избыточной пачки
        if (!IsValid(input))
            return;
        _pending[input.Tick] = input;
    }

    // Вызывается из серверного тика.
    public void ServerTick(float dt)
    {
        // Обрабатываем не больше нескольких вводов за тик: иначе клиент,
        // присылающий ввод «вперёд», получит ускорение.
        int processed = 0;
        while (_pending.Count > 0 && processed < 3)
        {
            InputPayload input = _pending.Values[0];
            _pending.RemoveAt(0);

            _state = MovementSimulation.Step(_state, input, dt, _controller);
            _lastInput = input;
            _lastProcessedTick = input.Tick;
            processed++;
        }

        Network.SendState(_state);
    }

    private static bool IsValid(InputPayload input)
    {
        // Сервер не верит длине вектора: нормализуем подозрительный ввод.
        return input.Move.sqrMagnitude <= 1.01f;
    }
}

public interface IServerSender
{
    void SendState(StatePayload state);
}

Самое важное в серверном ответе — номер тика. Сервер говорит не просто «ты стоишь здесь», а «ты стоишь здесь после обработки твоего ввода №1520». Без этого номера клиент не сможет понять, какие из его предсказаний уже подтверждены, а какие ещё в пути.

Ограничение «не больше трёх вводов за тик» — простая защита от speed hack. Клиент, который подменяет часы и шлёт вводы быстрее, чем положено, упрётся в этот лимит, а лишний ввод будет копиться в очереди. В боевой версии очередь тоже ограничивают по размеру и выкидывают самые старые вводы.

Ещё один момент: что делать, если ввод на этом тике так и не пришёл? Обычный вариант — повторить последний известный ввод (_lastInput), как будто игрок продолжает держать те же клавиши. Это лучше, чем остановить персонажа, потому что чаще всего игрок действительно продолжает бежать. Но тогда серверный тик уже не совпадёт с клиентским номером, поэтому многие реализации вводят отдельный счётчик серверных тиков и сопоставляют его с клиентскими по смещению. Для первого прототипа хватит варианта «ждём ввод, а если очередь пуста — стоим».

Шаг 7. Согласование: откат и повторная симуляция

Теперь самое интересное. Клиенту пришло авторитарное состояние на тик T. Он смотрит в свой буфер: что он сам предсказал на тик T?

private const float PositionTolerance = 0.01f;

private void Reconcile()
{
    StatePayload server = _latestServerState;

    if (!_states.TryGet(server.Tick, out StatePayload predicted))
        return; // такой старый тик уже вытеснен из буфера

    float error = Vector3.Distance(predicted.Position, server.Position);
    if (error < PositionTolerance && predicted.Grounded == server.Grounded)
        return; // предсказание верное, ничего не делаем

    Vector3 visualBefore = _visual.position;

    // 1. Откат: принимаем серверное состояние как истину.
    StatePayload state = server;
    _states.Set(server.Tick, state);

    // 2. Повтор: заново проигрываем все неподтверждённые вводы.
    for (uint tick = server.Tick + 1; tick <= _clock.CurrentTick; tick++)
    {
        if (!_inputs.TryGet(tick, out InputPayload input))
            break;

        state = MovementSimulation.Step(state, input, _clock.TickInterval, _controller);
        _states.Set(tick, state);
    }

    _current = state;
    _states.TryGet(_clock.CurrentTick - 1, out _previous);

    // 3. Запоминаем визуальный скачок, чтобы сгладить его, а не телепортировать модель.
    _visualOffset += visualBefore - _current.Position;
}

Разберём по шагам, что здесь происходит.

  1. Клиент берёт собственное предсказание на тик сервера. Если оно совпадает с серверным с точностью до сантиметра, делать ничего не нужно. Это самый частый случай, и он почти ничего не стоит.
  2. Если расхождение есть, клиент перезаписывает своё состояние на тике T серверным. С этого момента прошлое «исправлено».
  3. Затем он берёт из буфера вводы тиков T+1, T+2 и так далее до текущего и прогоняет их через ту же функцию. Получается новое предсказание настоящего, но уже от правильной стартовой точки.

При пинге 100 мс и 60 тиках в секунду повтор — это примерно 6–8 вызовов CharacterController.Move. Для одного персонажа это ничтожная нагрузка. Проблемы с производительностью начинаются, если предсказывать много объектов сразу или пересимулировать физику всей сцены.

Порог PositionTolerance подбирайте экспериментально. Слишком маленький (0.0001) приведёт к постоянным откатам из-за погрешностей сериализации float. Слишком большой (0.5) позволит клиенту заметно расходиться с сервером без коррекции.

Шаг 8. Плавная отрисовка и сглаживание ошибки

Если просто ставить модель в _current.Position, получим две проблемы. Первая — при 60 тиках и 144 FPS движение будет ступенчатым: модель стоит на месте несколько кадров, потом прыгает. Вторая — после отката с ошибкой модель телепортируется на десятки сантиметров.

Первая решается интерполяцией между двумя последними тиками по Alpha из часов. Вторая — смещением, которое плавно затухает.

private Vector3 _visualOffset;
[SerializeField] private float _errorDecay = 12f;

private void RenderInterpolated()
{
    Vector3 position = Vector3.Lerp(_previous.Position, _current.Position, _clock.Alpha);

    // Ошибка экспоненциально уходит к нулю, независимо от FPS.
    _visualOffset *= Mathf.Exp(-_errorDecay * Time.deltaTime);
    if (_visualOffset.sqrMagnitude < 0.000001f)
        _visualOffset = Vector3.zero;

    // Если ошибка огромная (телепорт, респаун), не сглаживаем, а прыгаем сразу.
    if (_visualOffset.sqrMagnitude > 4f)
        _visualOffset = Vector3.zero;

    _visual.position = position + _visualOffset;
}

Логика такая: в момент отката модель визуально остаётся там, где была, а смещение _visualOffset равно разнице между старой и новой позицией. Дальше оно экспоненциально уменьшается, и модель за 200–300 мс «доезжает» до правильного места. Игрок видит лёгкий дрейф вместо рывка. Формула Mathf.Exp(-k * dt) даёт одинаковое затухание при любом FPS, в отличие от наивного Lerp(offset, zero, 0.1f).

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

Шаг 9. Чужие игроки: интерполяция, а не предсказание

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

using System.Collections.Generic;
using UnityEngine;

public sealed class InterpolatedRemote : MonoBehaviour
{
    private struct Snapshot
    {
        public double Time;
        public Vector3 Position;
    }

    [SerializeField] private float _delay = 0.1f; // 100 мс в прошлое

    private readonly List<Snapshot> _snapshots = new();

    public void OnServerState(StatePayload state, double serverTime)
    {
        _snapshots.Add(new Snapshot { Time = serverTime, Position = state.Position });
        if (_snapshots.Count > 32)
            _snapshots.RemoveAt(0);
    }

    public void Render(double estimatedServerTime)
    {
        double renderTime = estimatedServerTime - _delay;

        // Ищем пару снимков, между которыми лежит renderTime.
        for (int i = _snapshots.Count - 1; i > 0; i--)
        {
            Snapshot a = _snapshots[i - 1];
            Snapshot b = _snapshots[i];
            if (a.Time <= renderTime && renderTime <= b.Time)
            {
                float t = (float)((renderTime - a.Time) / (b.Time - a.Time));
                transform.position = Vector3.Lerp(a.Position, b.Position, t);
                return;
            }
        }

        // Данных нет (лаг): стоим в последней известной точке.
        if (_snapshots.Count > 0)
            transform.position = _snapshots[^1].Position;
    }
}

Задержка интерполяции — компромисс. Чем она больше, тем устойчивее картинка к потерям пакетов, но тем сильнее чужие игроки «отстают» от реальности. Для 30 тиков в секунду разумно 100 мс, для 60 — 50–70 мс. Если в вашем шутере попадание считает сервер, ему придётся учитывать эту задержку через lag compensation: откатывать хитбоксы других игроков на момент, который видел стрелявший. Это отдельная большая тема.

Типичные ошибки и как их найти

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

Разный шаг времени

Клиент вызывает Step с Time.deltaTime, сервер — с фиксированным интервалом. Результат: постоянные мелкие откаты и «дрожание» персонажа. Лечится только фиксированным тиком везде.

Скрытое состояние вне StatePayload

Таймер рывка, счётчик двойного прыжка, кулдаун способности живут в полях MonoBehaviour, а в состояние не попадают. После отката позиция восстанавливается, а таймер нет, и повторная симуляция расходится с сервером. Правило: если поле читается внутри Step, оно должно быть в StatePayload.

Ввод читается внутри симуляции

Если Step сам обращается к Input.GetAxis, то при повторной симуляции он прочитает текущий ввод, а не тот, что был на тике T. Ввод должен приходить только аргументом.

Мир изменился между предсказанием и повтором

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

Как отладить

Самый полезный инструмент — счётчик расхождений. Логируйте каждый откат с номером тика и величиной ошибки:

if (error >= PositionTolerance)
{
    _mispredictions++;
    Debug.Log($"Откат на тике {server.Tick}: ошибка {error:F3} м, " +
              $"повтор {_clock.CurrentTick - server.Tick} тиков");
}

В нормальной ситуации, когда игрок бегает по пустому уровню без других игроков, откатов быть не должно вообще. Если они идут постоянно, ищите расхождение в симуляции: сравните поля predicted и server покомпонентно и посмотрите, какое из них «уплывает» первым. Для проверки при плохой сети в Netcode for GameObjects есть Network Simulator из пакета Multiplayer Tools: он добавляет задержку, джиттер и потери пакетов. Если пользуетесь другим транспортом, похожего эффекта можно добиться, искусственно задерживая очередь пакетов в своём коде.

Когда предсказание не нужно

Не каждой сетевой игре нужна вся эта машинерия.

  • Пошаговые игры и карточные игры прекрасно живут на простых запросах «сделай ход» и ответах сервера. Задержка в 100 мс там незаметна.
  • Кооператив с друзьями, где хост — один из игроков, часто обходится клиентской авторитарностью для движения: клиент двигает своего персонажа сам, а сервер лишь проверяет, что скорость в разумных пределах. Это проще и даёт мгновенный отклик без отката.
  • RTS с сотнями юнитов обычно используют детерминированный lockstep: передаются только команды, а все клиенты симулируют мир одинаково. Это другая архитектура со своими требованиями к детерминизму, в том числе к арифметике с фиксированной точкой.

Предсказание с согласованием оправдано там, где важны одновременно мгновенный отклик и защита от читов: соревновательные шутеры, файтинги, динамичные платформеры и экшены с PvP.

Итоги

Схема клиентского предсказания держится на нескольких простых идеях, и каждая обязательна:

  • симуляция идёт фиксированными тиками с номерами, одинаковыми на клиенте и сервере;
  • движение описывает одна чистая функция Step(state, input, dt), которую вызывают и клиент, и сервер;
  • клиент хранит в кольцевых буферах свой ввод и предсказанные состояния за последние секунды;
  • сервер отвечает состоянием вместе с номером последнего обработанного тика;
  • при расхождении клиент откатывается к серверному состоянию и заново проигрывает неподтверждённые вводы;
  • визуальная модель отделена от симуляции: она интерполируется между тиками и плавно гасит ошибку после отката;
  • чужие игроки показываются чуть в прошлом через интерполяцию снимков.

Начните с одного персонажа на CharacterController, добавьте счётчик откатов и прогоните игру с искусственной задержкой 150 мс и потерей 5% пакетов. Если управление остаётся мгновенным, а откаты случаются только при столкновениях с другими игроками, система работает правильно, и на неё уже можно навешивать способности, рывки и стрельбу, не забывая добавлять их состояние в StatePayload.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также