UNET — новая сетевая технология в Unity 3D

в 11:49, , рубрики: game development, multiplayer, unity3d, игры, разработка игр, сети

Некоторое время назад, на конференции Unite Asia, мы сообщили о разработке новых мультиплейерных инструментов, технологий и служб для разработчиков Unity. Внутреннее название этого проекта — UNET, что означает просто Unity Networking. Но наши планы простираются далеко за пределы простой работы с сетью. Как вы все знаете, основной целью Unity является демократизация процесса разработки игр. Команда Unity Networking хочет демократизировать разработку многопользовательских игр. Мы хотим, что бы все разработчики игр могли разрабатывать многопользовательские игры любого типа с любым количеством игроков. Само собой это не самяа простая задача, но мы все уже решали ее в прошлом и очень хотим сделать это снова (потому что это действительно классно!). Мы решили разделить нашу общую цель на несколько фаз, что должны быть хорошо знакомо Unity-разработчикам. Согласно этому подходу мы выпустим фазу 1, получим отзывы пользователей, учтем их в нашей работе, что бы сделать следующую фазу еще лучше и повторим этот цикл. Для UNET фазой 1 будет то, что мы называем — Multiplayer Foundation — о ней мы расскажем чуть ниже. Фаза 2 будет построена на основе фазы 1 и предоставит технологию создания игр с авторизацией на сервере, которую мы называем Simulation Server, о ней в следующих статьях. В фазе 3 мы добавим возможность координировать множество Simulation Servers с помощью системы Master Simulation Server. Как всегда, точную дату выпуска назвать невозможно, особенно с учетом сбора отзывов от наших пользователей. Но мы можем сказать, что фаза 1 будет частью цикла релизов 5.х, а фаза 2 сейчас находится на этапе исследований.

UNET — новая сетевая технология в Unity 3D

Перед тем, как присоединиться к Unity, члены нашей сетевой команды работали по большей части над MMO вроде Ultima Online, Lord of the Rings Online, Dungeons and Dragons Online, Marvel Heroes, Need for Speed Online и World of Warcraft. У нас масса энтузиазма и огромный опыт создания многопользовательских игр, технологий и инфраструктуры. Миссия Unity была известна каждому из нас и всегда казалась очень привлекательной. Мы не смогли отказаться от возможности заняться чем-то по настоящему великим, вроде реализации этой мечты в области мультиплейера. Так что мы ушли с предыдущих мест работы и присоединились к Unity ради воплощения этой мечты в реальность. Сейчас мы усердно работаем над этими инструментами, технологиями и службами, чтобы любой мог воплотить в действительность свою мечту о многопользовательской игре.

Так что же мы подразумеваем под Multiplayer Foundation в Фазе 1? Вот ее основные части:

— высокопроизводительный транспортный протокол на основе UDP, поддерживающий все типы игр

— низкоуровневый API (Low Level API — LLAPI), обеспечивающий полный контроль через сокетоподобный интерфейс

— высокоуровневый API (High Level API — HLAPI), обеспечивающий простую и безопасную модель клиент/сервер

— Matchmaker Service, обеспечивающий базовую функциональность по созданию комнат и помощи игрокам в поисках друг друга

— Relay Server, решающией проблемы со связью между игроками, пытающимися соединиться через файрволлы

Учитывая некоторые исторически сложившиеся ограничения и грандиозную цель, нам стало очевидно, что придется начинать с нуля. Так как нашей целью была поддержка всех типов игр и любого количества соединений, мы начали с нового, высокопроизводительного транспортного слоя, основанного на UDP. Мы в курсе, что многим играм вполне хватает TCP, но быстрым играм все равно необходим UDP, так как TCP задерживает последние пакеты, если они прибывают не по порядку.

На основании этого нового транспортного слоя мы построили два API. Высокоуровневый High Level API (HLAPI), обеспечивает простую и безопасную клиент-серверную модель. Если вы не являетесь сетевым инженером и хотите просто сделать многопользовательскую игру, вас заинтересует HLAPI.

Так же мы учли отзывы о старой системе: некоторые пользователи хотели получить низкоуровневый доступ для большего контроля. Теперь у на есть низкоуровневый Low Level API (LLAPI), который обеспечивает более сокетоподобный (socket-like) интерфейс для транспортного уровня. Если вы являетесь сетевым инженером и хотите построить собственную модель сети или просто тонко настроить сетевую производительность, то вас заинтересует LLAPI.

Служба подбора игроков Matchmaker используется для настройки комнат в ваших многопользовательских играх и помощи игрокам в поиске друг друга. Relay Server гарантирует, что ваши игроки всегда смогут соединиться друг с другом.

Мы на собственном опыте убедились, что создание многопользовательских игр приносит немало боли. Multiplayer Foundation — это новый набор простых в использовании, профессиональных сетевых технологий, инструментов и инфраструктуры для безболезненного создания многопользовательских игр. Как мне кажется, вполне можно сказать что создание многопользовательской игры требует неплохого знания сетей и протоколов. Вы либо преодолеваете болезненно крутую обучающую кривую самостоятельно, либо ищете сетевого инженера. Пройдя через это, вам приходится решать проблему обеспечения игроков средствами для поиска друг друга. Решив эту проблему вам приходится разбираться с обеспечением игроков возможностью соединиться друг с другом, что может быть очень непросто, если они находятся за фаерволами с NAT. Чтобы справиться со всем этим вам придется создать инфраструктуру приличного размера, что не особо приятно и не имеет ничего общего с разработкой игр. После этого вам придется думать о динамическом масштабировании вашей инфраструктуры, правильная реализация которого обычно требует определенного опыта.

Фаза 1 избавит вас от всех этих наболевших проблем. HLAPI устранит необходимость глубокого понимания сетевых технологий. Но если вы сетевой инженер и хотите сделать все по своему, то для вас всегда будет доступен LLAPI. Matchmaker решит ваши проблемы по обеспечению игроков возможностью найти друг друга. Relay Server решит ваши проблемы по обеспечению игроков возможностью действительно соединиться друг с другом. Мы также решим вашим проблемы по построению необходимой инфраструктуры и ее динамическому масштабированию. Matchmaker и Relay Server будут жить в облаке Unity Multiplayer Cloud. Так что не только физические серверы, но и процессы будут маштабироваться в зависимости от спроса.

Высокоуровнеовой API UNET и SyncVar

Введение и требования

Немного вводной информации. Общепринятой практикой для сетевых игр является наличие сервера, которому принадлежат объекты, и клиентов, которым надо сообщить, что данные в этих объектах изменились. Для примера, в боевой игре, жизнь игрока должна быть видна всем игрокам. Это требует наличия переменной-члена (member-variable) в классе скрипта, которое отсылается всем клиентам при изменении на сервере. Вот пример простого класса для боя:

class Combat : MonoBehaviour
{
    public int Health;
    public bool Alive;

    public void TakeDamage(int amount)
    {
        if (amount >= Health) {
            Alive = false;
            Health = 0;
        } else {
            Health -= amount;
        }
    }
}

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

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

1. Минимизировать использование памяти, не храня теневых копий переменных

2. Минимизировать использование полосы пропускания, посылая только те состояния, которые действительно изменились (инкрементные обновления)

3. Минимизировать использование процессора, не проверяя постоянно, изменилось ли состояние

4. Минимизировать расхождения протокола и сериализации, не заставляя разработчиков вручную писать функции сериализации

5. Не требовать от разработчиков прямо отмечать переменные как грязные

6. Работать со всеми поддерживаемыми Unity языками программирования

7. Не нарушать сложившийся процесс разработки

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

9. Позволить системе руководствоваться мета-данными (настраиваемым аттрибутами (custom attributes))

10. Обрабатывать и простые и сложные типы

11. Не использовать отражения во время выполнения.

Весьма амбициозный список требований!

Старая сетевая система

В существующей сетевой системе Unity есть “ReliableDeltaCompressed” тип синхронизации, который производит синхронизацию состояния, обеспечивая функцию OnSerializeNetworkView(). Эта функция внедряется в объекты с компонентом NetworkView и написанный разработчиком код сериализации пишет в (или читает из) предоставляемого потока байтов. Содержимое этого потока байтов кэшируется движком и если при следующем вызове функции результат не совпадает с кэшированной версией, объект считается грязным и его состояние посылается к клиентам. Приведем пример возможной функции сериализации:

void OnSerializeNetworkView (Bitstream stream, NetworkMessageInfo info)
{
    float horizontalInput = 0.0f;
    if (stream.isWriting) {
        // Sending
        horizontalInput = Input.GetAxis ("Horizontal");
       	stream.Serialize (horizontalInput);
    } else {

        // Receiving
        stream.Serialize (horizontalInput);
        // ... do something meaningful with the received variable
    }
}

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

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

Генерация кода для SyncVars

В ходе работы над новой системой синхронизации состояния в UNET наша команда выработала решение с генерацией кода на основании настраиваемых аттрибутов. В пользовательском коде оно выглядит вот так:

using UnityEngine.UNetwork;
class Combat : UNetBehaviour
{
    [SyncVar]
    public int Health;

    [SyncVar]
    public bool Alive;
}

Этот новый настраиваемый аттрибут говорит системе о том, что переменные экземпляра Health и Alive нуждаются в синхронизации. Теперь разработчику не надо писать функцию сериализации, так как у генератора кода есть данные из настраиваемых аттрибутов, на основании которых он сможет сгенерировать превосходные функции сериализации и десериализации с правильным порядком и типами. Сгенерированные функции будут выглядеть примерно так:

public override void UNetSerializeVars(UWriter writer)
{
    writer.WriteInt(Health);
    writer.WriteBool(Alive);
}

Так как эта функция переопределяет виртуальную функцию в базовом классе UNetBehaviour, то при сериализации игрового обьекта скриптовые переменные будут сериализованы автоматически. После этого они будут распакованы на другом конце с помощью соответствующей функции десериализации. Несовпадения невозможны, так как при добавлении новой [SyncVar] переменной код обновляется автоматически.

Эти данные теперь доступны для редактора, так что окно инспектора может показать больше информации:

UNET — новая сетевая технология в Unity 3D

Но при подобном подходе все еще остается ряд проблем. Функция всегда посылает все состояние — она не инкрементна, так что при изменении одного члена объекта будет послано состояние всего объекта. И как мы узнаем, когда надо вызвать функцию сериализации? Не очень-то эффективно посылать состояние, если ничего не изменилось.

Мы побороли это с помощью свойств и грязных меток (dirty flags). Кажется естественным, что каждую [SyncVar] переменную можно обернуть в свойство, которое будет проставлять грязные метки при ее изменении. Этот подход оказался частично успешным. Наличие битовой маски с грязными метками позволило генератору кода выполнять инкрементные обновления. Сгенерированный код стал выглядеть вот так:

public override void UNetSerializeVars(UWriter writer)
{
    Writer.Write(m_DirtyFlags)
    if (m_DirtyFlags & 0x01) { writer.WriteInt(Health); }
    if (m_DirtyFlags & 0x02) { writer.WriteBool(Alive); }
    m_DirtyFlags = 0;
}

Теперь функция сериализации может прочитать маску с грязными меткам и сериализовывать только те переменные, которые надо записать в поток. Мы получаем эффективное использование полосы пропускания и возможность узнать, грязен ли объект. Для пользователя это все еще выполняется полностью автоматически. Но как эти свойства будут работать?

Предположим, что мы пытаемся обернуть [SyncVar] переменные экземпляра:

using UnityEngine.UNetwork;
class Combat : UNetBehaviour
{
    [SyncVar]
    public int Health;

    // generated property
    public int HealthSync {
        get { return Health; }
        set { m_dirtyFlags |= 0x01;  Health = value; }
    }
}

Такое свойство выполняет поставленную задачу, но имеет неверное имя. Функция TakeDamage() из вышеприведенного примера использует Health, а не HealthSync, так что она проигнорирует свойство. Пользователь вообще не сможет напрямую использовать свойство HealthSync, так как оно не существует до выполнения генерации кода. Можно было бы выполнять ее в два шага, когда на первом этапе генерится автоматический код, а на втором пользователь обновляет свой код — но это очень хрупко. Такой подход подвержен ошибкам при компиляции, которые невозможно исправить без переписывания больших кусков кода.

Другим вариантом могло бы быть требование к разработчикам писать вышеприведенные свойства для каждой [SyncVar] переменной. Этот подход добавляет работы программистам и потенциально подвержен ошибкам. Битовые маски в пользовательском и сгенеренном коде должны точно совпадать, так что добавление или удаление [SyncVar] переменных будет крайне деликатным процессом.

Представляем Mono Cecil

Таким образом, нам надо сгенерировать свойства-обертки и заставить исходный код использовать их даже в том случае, если он не подозревает об их существовании. К счастью, для Mono есть инструмент под названием Cecil, который делает именно это. Cecil способна загружать сборки Mono в формате ECMA CIL, изменять их и записывать обратно.

В этот момент все становится немного безумным. Генератор кода UNET создает свойства-обертки, затем он находит все места в коде, где используются исходные переменные, после чего заменяет ссылки на эти переменные ссылками на свойства-обертки и вуаля! Теперь пользовательский код вызывает свежесозданные свойства не требуя никакой работы от пользователя.

Так как Cecil работает на уровне CIL, то появляется дополнительное преимущество в виде поддержки всех языков, так как они все компилируются в один формат.

Сгенерированный CIL для итоговой сериализации, который вставляется в сборку со скриптом, теперь выглядит так:

IL_0000: ldarg.2
IL_0001: brfalse IL_000d

IL_0006: ldarg.0
IL_0007: ldc.i4.m1
IL_0008: stfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits

IL_000d: nop
IL_000e: ldarg.1
IL_000f: ldarg.0
IL_0010: ldfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_0015: callvirt instance void [UnityEngine]UnityEngine.UNetwork.UWriter::UWriteUInt32(uint32)
IL_001a: ldarg.0
IL_001b: ldfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_0020: ldc.i4 1
IL_0025: and
IL_0026: brfalse IL_0037

IL_002b: ldarg.1
IL_002c: ldarg.0
IL_002d: ldfld valuetype Buf/BufType Powerup::mbuf
IL_0032: callvirt instance void [mscorlib]System.IO.BinaryWriter::Write(int32)

IL_0037: nop
IL_0038: ldarg.0
IL_0039: ldc.i4.0
IL_003a: stfld uint32 [UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_003f: ret

К счастью, ILSpy может конвертировать CIL в C# и наоборот, так что мы сможем посмотреть сгенерированный CIL как C#. ILSpy великолепный инструмент для работы со сборками Mono/.Net. C# выглядит вот так:

public override void UNetSerializeVars(UWriter writer, bool forceAll)
{
    if (forceAll)
    {
        this.m_DirtyBits = 4294967295u;
    }
    writer.UWriteUInt32(this.m_DirtyBits);
    if ((this.m_DirtyBits & 1u) != 0u)
    {
        writer.Write((int)this.mbuf);
    }
    this.m_DirtyBits = 0u;
}

Давайте посмотрим, насколько это соотвествует нашим требованиям:

1. Нет теневых копий переменных

2. Инкрементные обновления

3. Нет проверок на изменение состояния

4. Нет написанных вручную функций сериализации

5. Нет прямых проверок на грязность (No explicit dirty calls)

6. Работает со всеми языками программирования, поддерживаемыми Unity

7. Не затрагивает привычный процесс разработки

8. Не требует от разработчика ручной работы

9. Основано на метаданных

10. Работает со всеми типами (с новыми сериализаторами UWriter/UReader)

11. Не использует отражения во время выполнения

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

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

Низкоуровневой LLAPI и транспортный уровень UNET

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

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

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

Ориентируясь на эти два типа мы разделили нашу сетевую библиотеку на две разные части: высокоуровневый HLAPI (high-level API) и низкоуровневый LLAPI (low-level API).

В этой части разговор пойдет о низкоуровневом API и структуре библиотеки, которые основаны на следующих принципах:

Производительность, производительность, производительность

LLAPI — это тонкий слой поверх UDP сокетов, большая часть работы производится в отдельном потоке (поэтому LLAPI может быть настроен на использование только главного потока). В нем нет никакого динамического выделения памяти и никакой тяжелой синхронизации (большая часть библиотеки использует синхронизацию на основе барьеров доступа к памяти (memory barrier synchronization) с небольшим количеством атомарных операций приращения/уменьшения (increment/decrement operation)).

Если что-то можно сделать на C#, то на нем и надо делать

Мы решили дать доступ только к тому, что действительно необходимо нашим пользователям. Подобно сокетам BSD LLAPI поддерживает только одну абстракцию — обмен сырыми двоичными сообщениями. В нем нет никаких tcp-подобных потоков, сериализаторов или RPC вызовов, только низкоуровневые сообщения.

Гибкость и настраиваемость? Да, пожалуйста...

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

Простота и приятность

Мы постарались спроектировать LLAPI по возможности похожим на API сокетов BSD

Сетевой и транспортный слои

В логическом плане низкуровневая библиотека UNET — это набор сетевых протоколов, построенных поверх UDP и включающих в себя «сетевой» слой и «транспортный» слой. Сетевой слой используется для соединения между узлами, доставки пакетов и контроля над возможными утечками и заторами (possible flow and congestion). Транспортный слой работает с «сообщениями», принадлежащими различным каналам связи.

UNET — новая сетевая технология в Unity 3D

У каналов есть два назначения, они могут разделять сообщения логически и обеспечивать различные гарантии доставки и качество обслучживания (delivery grants or quality of service).

Настройка каналов — это часть процедуры настройки, о которой мы расскажем в будущих статьях. На данный момент давайте просто примем, что настройка выглядит как «Моя система будет содержать до 10 соединений, каждое соединение будет иметь 5 каналов, канал 0 будет иметь этот тип, канал 1 будет иметь другой тип и так далее». Последняя часть предложения определяется как:

UNET — новая сетевая технология в Unity 3D

Второй параметр — это номер канала, последний — тип канала или качество обслуживания канала (гарантии доставки (delivery grant)).

UNET (на данный момент) поддерживает следующие QOS

— Unreliable: ненадежное сообщение, которое может быть потеряно из-за сетевых проблем или внутреннего переполнения буфера, аналогично UDP пакету. Пример: короткие записи в журнал.

— UnreliableFragmented: Максимальная длина пакета неизменна, но временами вы скорее всего захочете послать «большие» сообщения. Этот тип канала перед отправлением будет разбирать ваши сообщения на фрагменты и собирать их обратно перед получением. Так как такое качество обслуживание ненадежно, доставка не гарантируется. Пример: длинный журнал.

— UnreliableSequenced: Канал обеспечивает порядок доставки, но так как это качество обслуживания ненадежно, сообщение может быть потеряно. Пример: голосо, видео.

— Reliable: Канал гарантирует доствку (или разрыв соединения), но не гарантирует порядок. Пример: передача урона.

— ReliableFragmented: то же, что и UnreliableFragmented, но в дополнение к нему гарантирует доставку. Пример: групповой урон.

— ReliableSequenced: то же, что и UnreliableSequenced, но дополнительно гарантирует доставку. Этот QOS аналогичен потоку TCP. Пример: передача файлов и патчей.

— StateUpdate: ненадежный тип канала, принудительно сбрасывающий пакеты старше получаемого/отправляемого. Если при передаче буфер содержит более одного сообщения, отправлено будет только самое свежее. Если буфер получателя при чтении содержит более одного сообщения, только самое свежее будет доставлено. Пример: передача расположения.

— AllCostDelivery: очень похож на Reliable, но есть отличия. Надежный канал будет повторно отсылать сообщения на основании времени прохождения сигнала в обоих направлениях (round trip time value (RTT)), которое определеяется динамически, в то время как AllCostDelivery будет автоматически пересылать сообщения после определенного промежутка времени (задается в настройках). Это может быть полезно для маленьких но важных сообщений: «Я попал в голову игроку А» или «Начинается мини-игра». Пример: игровые события вроде вылета пуль.

Давайте рассмотрим типичный вызов функции LLAPI:

1. Инициализация библиотеки

UNET — новая сетевая технология в Unity 3D

2. Настройка сети: топология, количество каналов, их типы, различные таймауты и размеры буферов (это будет рассмотрено в других статьях).

3. Создание сокета

UNET — новая сетевая технология в Unity 3D

Эта функция откроет сокет на порте 5000 на всех сетевых интерфейсах и вернет integer значение как описание сокета

4. Соединение с другим узлом

UNET — новая сетевая технология в Unity 3D

Эта функция отправит запрос на соединение «другому узлу» по адресу 127.0.0.1/6000. Она вернет целое значение как описание соединения с этим узлом. Вы получите событие соединения когда соединение будет установлено или событие обрыва, если соединение невозможно установить.

5. Посылаем сообщение

UNET — новая сетевая технология в Unity 3D

Последняя функция пошлет двоичные данные, содержащиеся в буфере, через сокет, описанный в hostId для описывающего узел connectionId используя канал 1 (в нашем случае это «reliable канал», так что доставка сообщения будет гарантирована)

6. Получение сетевых событий

Для получения сетевых событий мы выбираем модель опроса. Пользователь должн опрашивать функцию UTransport.Receive(), чтобы получать уведомления о сетевых событиях. Обратите внимание, что эта модель очень похожа на обычный вызов select() с нулевым таймаутом. Эта функция получает 4 разных события

UNETEventType.kConnectEvent — кто-то соединяется с вами или успешно установлено соединение, запрощенное через UTransport.Connect()

UNETEventType.kDisconnectEvent — кто-то отсоединяется от вас или соединение, запрошенное с помощью UTransport.Connect(), не может быть установлено по какой-то причине, о которой сообщит код ошибки.

UNETEventType.kDatatEvent — получены новые данные

UNETEventType.kNothing — ничего интересного не произошло

UNET — новая сетевая технология в Unity 3D

7. Послать запрос на разрыв соединения

Этот вызов функции пошлет запрос на разрыв соединения с connectionId на хост с hostId. Соединение будет немедленно закрыто и в будущем может быть использовано повторно.

UNET — новая сетевая технология в Unity 3D

Примечания

1. Статья собрана из трех записей в англоязычном блоге Unity

Announcing UNET – New Unity Multiplayer Technology
UNET SyncVar
All about the Unity networking transport layer

2. Примеры исходного кода в виде картинок были в изначальной статье на английском

Автор:

Источник


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js