Задать вопрос
@historydev

Как реализовать плавную синхронизацию предсказанной позиции с авторитетной сохранив мгновенный отклик?

Для отрисовки используется p5js.

Многовато строчек, если что-то непонятно - спросите меня.
код с комментариями
const physicsTickRate = 1 / 60; // Частота физического шага (сек)
let physicsAccumulator = 0; // Накопитель времени для плавности физики

let localPlayer; // Переменная для данных локального игрока
let lastInputId = 0; // Счетчик ID для каждого ввода (инпута)
let pendingInputs = []; // Список вводов, еще не подтвержденных сервером
const inputTickRate = 1 / 60; // Частота опроса ввода (сек)

function getInput() { // Функция захвата управления
    let x = 0; // Горизонтальная ось
    let y = 0; // Вертикальная ось

    if (keyIsDown(87)) y -= 1; // Если нажата W — вверх
    if (keyIsDown(83)) y += 1; // Если нажата S — вниз

    return {id: lastInputId++, x, y}; // Возврат объекта ввода с новым ID
}

function physicsTick(player, input) { // Расчет физики шага
    const playerInput = input || player.input; // Берем переданный ввод или текущий у игрока

    if (player.pos.y <= 100) { // Если игрок выше "пола"
        player.pos.x += player.speed * playerInput.x * physicsTickRate; // Двигаем по X
        player.pos.y += player.speed * playerInput.y * physicsTickRate; // Двигаем по Y
    } else { // Если упал ниже пола
        player.pos.y = 100; // Возвращаем на уровень пола
        if (player.vel.y > 0) player.vel.y = 0; // Сбрасываем вертикальную скорость
    }
}

function mockServer(ping, updateTickRate, physicsTickRate, onUpdateTick) { // Эмуляция сервера
    let isRunning = false; // Состояние работы сервера
    let physicsTimeout; // Ссылка на таймер физики
    let updateTimeout; // Ссылка на таймер рассылки данных

    const speed = 300; // Базовая скорость игрока
    const createInitialRemotePlayer = () => ({ // Функция создания чистого состояния
        id: 1, // ID игрока
        pos: {x: 0, y: 0}, // Начальная позиция
        vel: {x: 0, y: 0}, // Начальная скорость
        speed, // Скорость из константы
        input: {id: 0, x: 0, y: 0}, // Начальный ввод
    });

    let remotePlayer = createInitialRemotePlayer(); // Инициализация серверного игрока

    return { // Публичное API сервера
        async start() { // Запуск сервера
            if(isRunning) return; // Игнорируем, если уже запущен
            isRunning = true; // Ставим флаг работы

            const runPhysics = async () => { // Цикл серверной физики
                if(!isRunning) return; // Выход, если сервер остановлен
                physicsTick(remotePlayer); // Считаем физику
                physicsTimeout = setTimeout(runPhysics, physicsTickRate * 1000); // Планируем следующий шаг
            };

            const runUpdate = async () => { // Цикл рассылки обновлений
                if(!isRunning) return; // Выход, если сервер остановлен
                onUpdateTick(remotePlayer); // Отправляем данные клиенту
                updateTimeout = setTimeout(runUpdate, updateTickRate * 1000); // Планируем следующую рассылку
            };

            runPhysics(); // Запуск цикла физики
            runUpdate(); // Запуск цикла обновлений
        },
        stop(){ // Остановка сервера
            isRunning = false; // Снимаем флаг работы
            clearTimeout(physicsTimeout); // Удаляем таймер физики
            clearTimeout(updateTimeout); // Удаляем таймер обновлений
            remotePlayer = createInitialRemotePlayer(); // Полный сброс состояния игрока
        },
        restart() { // Перезапуск
            this.stop(); // Сначала стоп
            this.start(); // Потом старт
        },
        sendInput: input => remotePlayer.input = input, // Прием ввода от клиента
    };
}

function onUpdateTick(remotePlayer) { // Обработка данных от сервера на клиенте
    if(!localPlayer) { // Если локальный игрок еще не создан
        localPlayer = { // Создаем объект с методами p5.Vector
            id: remotePlayer.id, // Копируем ID
            pos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Вектор позиции
            lastPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Предыдущая позиция
            renderPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Позиция для отрисовки
            vel: createVector(remotePlayer.vel.x, remotePlayer.vel.y), // Вектор скорости
            speed: remotePlayer.speed, // Скорость
            input: remotePlayer.input, // Последний обработанный ввод
        };
    } else { // Если игрок уже есть (Client-side Reconciliation)
        localPlayer.speed = remotePlayer.speed; // Обновляем скорость от сервера
        localPlayer.vel.set(remotePlayer.vel.x, remotePlayer.vel.y); // Обновляем скорость сервера
        localPlayer.pos.set(remotePlayer.pos.x, remotePlayer.pos.y); // Сбрасываем позицию на серверную

        pendingInputs = pendingInputs.filter( // Убираем из очереди вводы,
            input => input.id > remotePlayer.input.id // которые сервер уже учел
        );

        for (const input of pendingInputs) { // Прогоняем оставшиеся вводы заново
            physicsTick(localPlayer, input); // Чтобы предсказать текущую позицию
        }
    }
}

const server = mockServer(0, 1 / 60, physicsTickRate, onUpdateTick); // Инициализация сервера

function setup() { // Настройка p5.js
    frameRate(240); // Высокий FPS для плавности отрисовки
    createCanvas(windowWidth, windowHeight); // Холст на всё окно

    const buttons = [ // Массив кнопок управления
        createButton('Start Server'), // Кнопка старта
        createButton('Restart Server'), // Кнопка рестарта
        createButton('Stop Server') // Кнопка стопа
    ];

    for(const [i, btn] of buttons.entries()) { // Стилизация кнопок
        btn.style('width', '100px'); // Ширина
        btn.style('height', '50px'); // Высота
        btn.position(width - 125, 75 * (i + 1)); // Позиция в углу
    }

    buttons[0].mousePressed(server.start); // Привязка старта
    buttons[1].mousePressed(server.restart.bind(server)); // Привязка рестарта с контекстом
    buttons[2].mousePressed(server.stop); // Привязка стопа

    server.start(); // Автозапуск сервера при старте страницы

    setInterval(() => { // Цикл отправки ввода (каждые 16мс)
        if (localPlayer) { // Если игрок существует
            localPlayer.input = getInput(); // Читаем клавиши
            server.sendInput(localPlayer.input); // Шлем на сервер
            pendingInputs.push(localPlayer.input); // Запоминаем для предсказания
        }
    }, inputTickRate * 1000); // Интервал в миллисекундах
}

function draw() { // Цикл отрисовки p5.js
    background(220); // Очистка фона

    if (!localPlayer) return; // Ждем появления игрока

    physicsAccumulator += deltaTime / 1000; // Добавляем время кадра в накопитель

    while (physicsAccumulator >= physicsTickRate) { // Если накопилось на целый тик
        localPlayer.lastPos = localPlayer.pos.copy(); // Сохраняем позу для интерполяции
        physicsTick(localPlayer); // Считаем предсказание физики
        physicsAccumulator -= physicsTickRate; // Вычитаем потраченное время
    }

    const alpha = physicsAccumulator / physicsTickRate; // Коэффициент смещения между тиками

    // Сглаживание позиции между физическим и рендер-кадром
    localPlayer.renderPos.lerp(p5.Vector.lerp(localPlayer.lastPos, localPlayer.pos, alpha), 0.1);

    translate(width / 2, height / 2); // Центрируем камеру

    line(-width / 2, 100, width / 2, 100); // Рисуем линию пола

    push(); // Сохраняем состояние трансформации
    translate(localPlayer.renderPos); // Переходим в позицию игрока
    rectMode(CENTER); // Рисуем квадрат из центра
    line(-width / 2, 0, width / 2, 0); // Рисуем локальную линию (ось)
    rect(0, 0, 100, 50); // Рисуем тело игрока
    pop(); // Восстанавливаем состояние трансформации
}

window.setup = setup; // Экспорт функций для p5.js
window.draw = draw; // Экспорт функций для p5.js
window.onresize = () => resizeCanvas(windowWidth, windowHeight); // Реакция на изменение окна


Лучший результат которого удалось добиться, но отклик отвратительный и не избавился от подёргиваний (они просто стали плавнее). - ровно это сглаживание и осуществляет эта строчка, я просто не вижу других ходов кроме интерполяции.
localPlayer.renderPos.lerp(p5.Vector.lerp(localPlayer.lastPos, localPlayer.pos, alpha), 0.1);


Проблему в чистом виде можно увидеть заменив эту строку translate(localPlayer.renderPos); на эту translate(localPlayer.pos);

Результат которого я пытаюсь добиться можно увидеть нажав кнопку "Stop Server", после чего "сервер" перестанет присылать обновления и заработает чистая, клиентская физика.

codepen (меняется по мере развития):


P.S: Целевой пинг который я хочу нивелировать 300-500+.
  • Вопрос задан
  • 116 просмотров
Подписаться 1 Простой Комментировать
Помогут разобраться в теме Все курсы
  • Нетология
    Разработчик игр на Unity
    13 месяцев
    Далее
  • Академия Эдюсон
    Разработчик игр на Unreal Engine: тариф PRO
    9 месяцев
    Далее
  • Академия Эдюсон
    Разработчик игр на Unity: тариф PRO
    6 месяцев
    Далее
Пригласить эксперта
Ответы на вопрос 1
opium
@opium
Просто люблю качественно работать
У тебя renderPos тащится за позицией с коэффициентом 0.1, отсюда весь лаг. Классический fix: сглаживай не позицию, а только ошибку коррекции. В onUpdateTick до reconciliation сохрани oldPos = localPlayer.pos.copy(), после replay pending inputs накопи разницу: offset.add(p5.Vector.sub(oldPos, localPlayer.pos)). Ну а в draw рисуй p5.Vector.add(localPlayer.pos, offset) и гаси offset через offset.mult(pow(0.001, deltaTime/1000)) чтоб не зависеть от fps. Ввод моментально в pos попадает, коррекции плавно сходят на нет.
Ответ написан
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы