
Создание мультиплеерных проектов - непростое занятие. Для облегчения понимания этого процесса можно начать с разработки пошаговых игр. В них не требуется постоянная синхронизация данных, поэтому они - отличная точка входа. В статье мы разберём реализацию «пошаговости» на примере крестиков-ноликов, используя Unity + Mirror.
Ключевой принцип мультиплеерных проектов
Основная идея заключается в разделении ответственности между сервером и клиентом. Сервер хранит состояния и содержит набор команд для работы с ними, клиент же - лишь отправляет запрос на исполнение этих команд.
Архитектура «пошаговости»
Мы намеренно оставляем за рамками подключение игроков, запуск сервера и прочие инфраструктурные детали. Наша цель - разобрать принцип.
В отрыве от этих деталей наш проект состоит из следующих сущностей:
TurnManager — основной модуль. Он принимает и валидирует ходы, обновляет состояние поля, проверяет выигрыш/ничью, передаёт ход и заканчивает игру.
Field — хранит состояние клеток, отрисовывает поле и по запросу TurnManager определяет, остались ли свободные ячейки и есть ли выигрышная комбинация.
PlayerManager даёт данные об игроках — их число, имя и символ (X/O) текущего игрока.
Управление ходами через Command
Command в Mirror — это метод с атрибутом [Command] в NetworkBehaviour, который вызывается на клиенте, а выполняется на сервере.
[Command(requiresAuthority = false)]
void CmdPassTurn(int cellId, NetworkConnectionToClient sender = null)
{
Player requestingPlayer = sender.identity.GetComponent<Player>();
if (requestingPlayer.playerId == currentPlayerId && field.CanPlaceFigureHere(cellId))
{
MakeMove(cellId);
}
}
[Server]
public void MakeMove(int fieldCellId)
{
field.PlaceFigureToCell(fieldCellId, playerManager.GetPlayerFigure(currentPlayerId));
if (field.CheckWinner() != 0)
{
RpcEndGame(true);
}
else if (field.MovesOver())
{
RpcEndGame(false);
}
else
{
SwitchNextPlayer();
}
}
Клиент кликает по ячейке, запрос, благодаря [Command], отправляется на сервер, на котором TurnManager валидирует ход, обновляет поле и расчитывает результат насчёт победы/ничьи. Если ход валиден, и его результат не привёл к победе/ничье, ход передаётся следующему игроку.
Переключение игрока на сервере
[SyncVar(hook = nameof(OnPlayerChanged))]
public int currentPlayerId = 0;
public void SwitchNextPlayer()
{
currentPlayerId = (currentPlayerId + 1) % playerManager.GetPlayerCount();
}
void OnPlayerChanged(int oldValue, int newValue)
{
playerManager.UpdateCurrentPlayer(newValue);
}
Для определения текущего игрока используется переменную currentPlayerId, она помечена как SyncVar, что означает, что она синхронизируется на всех клиентах при изменении на сервере, а хук будет вызывать метод OnPlayerChanged при его изменении на клиентах, что может быть полезно для обновления текущего игрока на интерфейсе игры.
Немного про [ClientRpc]
[ClientRpc]
public void RpcEndGame(bool hasWinner)
{
if (hasWinner)
{
OnGameEndedEvent?.Invoke(playerManager.GetPlayerName(currentPlayerId));
}
else
{
OnGameEndedEvent?.Invoke("Никто");
}
}
Метод, помеченый [ClientRpc], вызывается на сервере, а выполняется на всех клиентах‑наблюдателях объекта. В данном случае при окончании игры он выводит сообщение на экране у всех игроков.
Что такое SyncList?
public readonly SyncList<int> fieldList = new SyncList<int>();
В классе Field у нас есть синхронизируемый список fieldList. Работает также, как и переменная, помеченная [SyncVar]. Это позволяет всем клиентам своевременно обновлять у себя визуал игрового поля без каких-либо задержек.
Заключение
Мы реализовали базовый каркас пошаговой игры на Unity + Mirror: сервер принимает и валидирует ходы, обновляет состояние поля и рассылает результаты клиентам через SyncVar/SyncList и RPC. Этого шаблона достаточно, чтобы строить более сложные пошаговые игры. Если есть вопросы или конструктивная критика — рад выслушать. Спасибо за прочтение.