Мультиплеер или игра совместная сразу нескольких человек - явление далеко не новое, но, тем не менее, не теряющее своей актуальности. Давайте разберёмся, как это работает и что необходимо для добавление в своей игре возможностей мультиплеера.
Общие сведения о мультиплеере
По большому счёту, правил "когда можно, а когда нельзя" использовать в играх мультплеер - нет; всё упирается исключительно в проработанность игровых механик: игра должна предполагать возможность совместной игры. Поэтому первым и главным условием создания мультиплеера - его уместность. Однако, помимо организационных условий, есть и технические:
- Возможность сохранения, передачи и восстановления состояния управляемого объекта (персонажа).
- Краткость передаваемых данных: передаётся лишь необходимое и достаточное.
Подытожим: нужно краткое состояние игрока, которое можно передать и быстро восстановить.
Виды мультиплеера
Мультиплеер бывает двух видов: на одном или на разных терминалах. Проще говоря, это совместная игра за одним компьютером (приставкой) с разделением экрана или игра по сети.
Для первого случая, задержки обмена данными ничтожны - все изменения игровых данных моментально влияют на модель данных.
Модель данных - это некоторая логическая схема игрового пространства (мира), где игроки и игровое окружение описано и взаимодействует. Не включает отображение данных.
Второй случай предполагает более сложную схему взаимодействия: есть сервер, который обрабатывает подключения всех игроков и этот сервер, вероятней всего, независим от запущенных экземпляров игры участников; все игроки становятся "удалёнными", даже хозяин сервера (сервер вполне может запускаться на одной машине с клиентом), а все взаимодействия игрового мира перемещаются на сервер; игра же используется лишь как терминал, отображающий данные. Однако могут быть и иные варианты взаимодействия.
Что происходит на сервере
Сервер - всего лишь одно из звеньев клиент-серверного обмена, пусть и очень важное. Фактически, на его стороне присутствует некоторая логическая модель запуска игры, которая, обычно, предполагает 3 этапа:
- Сбор начальных сведений для запуска игры;
- Обеспечение запуска, загрузки и процесса игры;
- Завершение игрового сценария, подведение итогов.
Например, это может выглядеть так:
- Запрос открытых для подключения игр;
- Отправка запроса на присоединение к игре;
- Получение метаданных об игре, участниках, состоянии игровых и не игровых объектов, прочая информация;
- Получение уведомление об ожидании подключения всех участников;
- Отправка уведомления о начале игры с текущими метаданным (положение, параметры персонажа итп);
- Обмен данными об изменении состояния всех персонажей, включая часть их метаданных;
- Отправка информации о завершении игры.
Разумеется, внутренняя модель может быть иной, может предполагать наличие учётной записи для входа и включать контроль мошенничества.
Каналы взаимодействия клиент-сервер
Существует довольно много разновидностей обеспечения клиент-серверного взаимодействия, но, чаще всего, основой становятся сокеты, обеспечивающие двусторонний обмен данными.
Как вы, вероятно, знаете, на сегодняшний день активно используется два протокола обмена данными TCP, гарантирующий отправку данных и однонаправленный UDP, не отслеживающий, был ли доставлен пакет или нет. Первый используется для чувствительных данных (канал авторизации), второй - для быстро теряющих актуальность данных (интернет-радио).
Данные, передаваемые от клиента на сервер - важные, т.к. сервер обеспечивает контроль игры (и, зачастую, обрабатывает все внутриигровые механики). Данные от сервера клиентам о текущем состоянии игры (где какие изменения карты, что с персонажами и как они расположены на карте) - не столь важны: если какая-то часть данных будет утеряна, пришедшие следом пакеты позволят восстановить состояние.
Иными словами, лучше, если каждый клиент будет иметь два соединения с сервером: один, TCP, по которому он будете передавать свои актуальные данные; второй - UDP - по которому будет получать все игровые данные и на основании которых обновлять свою игровую модель.
Игровая модель данных
Не лишним будет и про неё сказать пару слов: это некоторый внутренний набор данных, определяющий процесс игры. Данные о игровых персонажах, разрушенных препятствиях, собранных призах, уничтоженных общих противниках, сработавших ловушках и тому подобное. Игровая карта может содержать огромное количество доступных для изменения данных и изменение чего-либо одним игроком должно сразу же передаться остальным.
Представьте себе игру Super Mario, где может одновременно играть сразу несколько человек и цель не просто добежать до замка, но и сделать это раньше других, набрав при этом как можно больше очков (монет, побеждённых врагов).
Модель такой игры будет содержать информацию о начальном состоянии этапа (не разрушенные блоки, не взятые секретные и явные призы, не собранные монеты, не раздавленные противники), но, по мере продвижения игроков к финишу - карта будет меняться и все эти изменения должны быть видны у каждого игрока.
Таким образом, требуется либо передавать всё время все возможные состояния карты, либо слать "дельты" - те изменения, которые произошли с тем, чтобы у клиентов была актуальная копия игровой модели.
Очевидно, если слать "дельты" по UDP, есть шанс потерять один или несколько ключевых пакетов, а слать постоянно состояние всей карты - дорого и неэффективно. Поэтому часть данных от сервера к клиенту можно передавать и по TCP, благо, канал для этого у нас есть. Главное, делать это не часто и в небольших количествах.
Переходя от теории к практике
Честно говоря, не думал, что теория окажется столько затянутой, это я её ещё сокращал как мог.
Давайте же напишем фреймворк, на базе которого можно будет развернуть клиент-серверное взаимодействие мультиплеера.
Во-первых, клиент с сервером должны иметь возможность установить, как мы разбирали, два подключения одновременно, а для этого нам потребуется 2 отдельных порта.
Во-вторых, требуется определить правила, согласно которым клиент и сервер обмениваются данными по TCP протоколу (данные требуется слать по-очереди, а если нечего слать - должны ходить пустые пакеты с некоторыми интервалом, гарантирующим отзывчивость игры и управления).
В-третьих, клиент и сервер должны обладать внутренними игровыми моделями, которые можно частично или полностью сериализовать для передачи с последующим восстановлением.
В-четвёртых, предусмотреть механизм актуализации данных как у клиента, так и у сервера с тем, чтобы безопасно восстановить модель данных и не допустить того, чтобы устаревшие данные были установлены поверх более новых.
Пишем сервер
Для сервера я выбрал nodejs (можно было написать и на Go, но т.к. клиент будет написан под веб, пусть лучше везде будет один и тот же язык; что-то может будет повторно использовать). Правда есть небольшая трудность - со стороны клиента можно использовать только вебсокеты, что накладывает некоторые ограничения. Поэтому я ограничусь одним каналом связи, благо у меня не будет больших объёмов данных для передачи.
Начнём с каркаса сервера: он просто принимает сообщения и отправляет уведомление обратно. Я использовал готовый пакет ws:
И под сервер написать простейший веб-клиент, который просто отправляет несколько сообщений и закрывает соединение.
Песочница
Добавим основу для разработки - пусть будет простая игра: на поле появляется в случайном месте предмет, а все игроки должны как можно скорее его схватить.
Предмет виден лишь при условии, что расстояние до него меньше некоторой константы. Поднятый предмет - добавляет одно очко. Всего появляется заранее определённое количество предметов, при взятии последнего - подсчёт очков и уведомление о победе или поражении. Для удобства отображения, свой игрок помечен красным цветом, прочие - серым. При столкновении двух игроков, каждый из них перемещается в случайное место экрана; границы экрана - сквозные (ушёл направо, вышел слева).
Все вычисления производятся на сервере, на клиенте - только отрисовка и управление.
Знакомство
Первое подключение - знакомство клиента и сервера; клиент отправляет сообщение о том, что хочет играть, сервер возвращает ему идентификатор. Кто первый подключился к серверу, тот и админ - принимает решение, когда начать игру. Сервер принимает подключение и отправляет ответ.
В это же время клиент получает данные от сервера и реагирует на них.
Наверное, нет большого смысла размещать тут постоянные изменения кода; главное, что надо понять на этом этапе - у нас идёт что-то вроде игры в пинг-понг. Клиент отправляет данные серверу, сервер отправляет данные клиенту, обмен идёт в обоих направлениях.
Сбор игроков
На этом этапе мы ждём, пока подключатся все игроки. У автора сервера (первого подключившегося) появляется кнопка "начать игру" и счётчик игроков; прочие же видят лишь счётчик игроков, но начать игру не могут (это блокируется на уровне сервера).
В принципе, ничего сложного; когда все игроки собрались (их количество достаточно) - можно начинать игру. Кто создал - тот и начинает. Ещё при инициализации отправляется некоторое количество служебных данных.
Фактически, сервер передаёт клиенту игровые настройки, которые будут использованы (в том числе данные карты и дальность обзора; они нужны для отрисовки).
Начало игры
После нажатия кнопки старт, сервер инициализирует карту и размещает на ней игроков - все эти данные отправляются клиентам и эти данные начинают отображаться. Параллельно включается управление. По сути, выполняется несколько процессов параллельно:
- клиент шлёт серверу зажатые кнопки управления, которые будут преобразованы в управляющий вектор;
- сервер постоянно обрабатывает данные игроков на столкновения;
- сервер с определённым интервалом шлёт текущие координаты всех игроков и немного дополнительной информации.
По сути - не так уж и много. Код, обрабатывающий подключения, выдался довольно длинным - не буду приводить его целиком. Главное - цель достигнута
Как по мне, получилось неплохо; задержки минимальны, а объекты двигаются на экране одновременно. Дальше необходимо добавить подарочки, которые увеличивают счёт игроков и разлёт от столкновения.
Обработка столкновений
Тема не новая, я уже несколько раз разбирал её в блоге. Ограничимся самой простой моделью пересечения - расстояние между игровыми объектам (а его всё равно придётся считать) - меньше радиуса (в конце концов наши объекты - шары).
Тут, наверное, самое интересное - это то, что вычислений расстояний между игроками производится "лесенкой": первый элемент сравнивается со всеми, начиная со второго, второй - со всеми, начиная с третьего и так далее. Ну а подбор призов - не особенно интересная история.
При "наезде" на подарок - голубой ромб - счётчик внутри увеличивается на 1; при достижении определённого счёта - видим сообщения о победе.
Или о поражении, если победил противник. Прикладываю запись сразу двух независимых вкладок, где идёт неравный бой меня и меня.
Заключение
Должен заметить, что этот проект мне понравился. Немного даже жаль, что в эту игру ни с кем поиграть не получится, но лишь немного: надоест она крайне быстро. Текущий вариант всё ещё довольно сырой и в логике как сервера, так и клиента довольно много изъянов, которые надо бы поправить. Однако, для демонстрационных целей - этого хватит.
Главное, были соблюдены все указанные этапы: ожидание игры и её запуск, игровой процесс и завершение игры.
Я проверил работу на 3 независимых вкладка - вышло относительно неплохо. Однако для более-менее сложной игры может потребоваться часть вычислений передать, всё же, на клиент. Во избежание неприемлемых задержек в сети.