Мультиплеер ENet: лобби, RPC и серверное состояние
Готовое лобби с сервером и двумя клиентами: отправитель, права RPC, повторы и отключение.
Запустить лобби
Добавьте LobbySession.cs и LobbyLab.cs в C#-проект 1.0.0. Создайте отдельный уровень с корнем Control, прикрепите LobbyLab, соберите и нажмите F6. Окно — 640×420 или больше. Сначала сервер и оба клиента должны показать 3 игрока и 0 готовых; сервер входит в число игроков.
Первая и вторая кнопки переключают готовность соответствующего клиента. После двух нажатий все три участника показывают 3 / 2. Третья кнопка отключает и снова подключает второго клиента. Отключённый клиент помечается явно, а сервер удаляет его готовность.
LobbyLab.cs
using Reckfell;
public partial class LobbyLab : Control
{
public LobbySession HostSession { get; private set; } = null!;
public LobbySession First { get; private set; } = null!;
public LobbySession Second { get; private set; } = null!;
public Label Status { get; private set; } = null!;
private bool _firstReady, _secondReady;
private bool _ru;
public override void _Ready()
{
_ru = TranslationServer.GetLocale().StartsWith("ru");
HostSession = new LobbySession { Name = "Host" }; AddChild(HostSession);
First = new LobbySession { Name = "First" }; AddChild(First);
Second = new LobbySession { Name = "Second" }; AddChild(Second);
var box = new VBoxContainer { Position = new Vector2(24, 24), Size = new Vector2(570, 300) }; AddChild(box);
box.AddChild(new Label { Text = _ru ? "ENet: сервер и два клиента" : "ENet: server and two clients" });
Status = new Label(); box.AddChild(Status);
var first = new Button { Text = _ru ? "Первый игрок: готовность" : "First player: toggle ready" }; box.AddChild(first);
first.Pressed += () => { if (First.RequestReady(!_firstReady) == Error.Ok) _firstReady = !_firstReady; };
var second = new Button { Text = _ru ? "Второй игрок: готовность" : "Second player: toggle ready" }; box.AddChild(second);
second.Pressed += () => { if (Second.RequestReady(!_secondReady) == Error.Ok) _secondReady = !_secondReady; };
var reconnect = new Button { Text = _ru ? "Второй игрок: отключить / подключить" : "Second player: disconnect / reconnect" }; box.AddChild(reconnect);
reconnect.Pressed += () => { if (Second.Active) Second.Stop(); else { _secondReady = false; Second.Join("127.0.0.1", HostSession.LocalPort); } };
Error host = HostSession.Host();
if (host != Error.Ok) { Status.Text = $"Host: {host}"; SetProcess(false); return; }
First.Join("127.0.0.1", HostSession.LocalPort); Second.Join("127.0.0.1", HostSession.LocalPort);
}
public override void _Process(double delta)
{
Status.Text = _ru
? $"Сервер: игроков {HostSession.Players}, готовы {HostSession.ReadyPlayers}\nКлиент 1: {ClientStatus(First)}\nКлиент 2: {ClientStatus(Second)}"
: $"Server: players {HostSession.Players}, ready {HostSession.ReadyPlayers}\nClient 1: {ClientStatus(First)}\nClient 2: {ClientStatus(Second)}";
}
private string ClientStatus(LobbySession session) => session.Connected
? $"{session.Players} / {session.ReadyPlayers}" : (_ru ? "отключён" : "disconnected");
}LobbySession.cs
using System;
using System.Collections.Generic;
using Reckfell;
public partial class LobbySession : Entity
{
public bool Active => _api is not null;
public bool Connected { get; private set; }
public bool IsServer => _api?.IsServer() == true;
public int PeerId => _api?.GetUniqueId() ?? 0;
public int LocalPort => IsServer ? _peer!.Host.GetLocalPort() : 0;
public int Players { get; private set; }
public int ReadyPlayers { get; private set; }
public int SnapshotSequence { get; private set; }
public int AcceptedRequests { get; private set; }
public int RejectedRequests { get; private set; }
public int LastSenderId { get; private set; }
public string LastFailure { get; private set; } = "";
public event Action? Joined;
private SceneMultiplayer? _api;
private ENetMultiplayerPeer? _peer;
private EntityPath? _rootPath;
private readonly Dictionary<int, bool> _ready = new();
private readonly Dictionary<int, int> _lastInput = new();
private int _nextInput;
private bool _pendingStop;
private bool _publishPending;
public Error Host(int port = 0)
{
if (Active) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (port < 0 || port > 65535) return Error.InvalidParameter;
var peer = new ENetMultiplayerPeer(); peer.SetBindIP("127.0.0.1");
Error error = peer.CreateServer(port, maxClients: 4, maxChannels: 2);
if (error != Error.Ok) { peer.Dispose(); return error; }
Attach(peer); Connected = true; _ready[1] = false; Publish(); return Error.Ok;
}
public Error Join(string address, int port)
{
if (Active) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (port <= 0 || port > 65535 || string.IsNullOrWhiteSpace(address)) return Error.InvalidParameter;
var peer = new ENetMultiplayerPeer(); Error error = peer.CreateClient(address, port, channelCount: 2);
if (error != Error.Ok) { peer.Dispose(); return error; }
Attach(peer); return Error.Ok;
}
private void Attach(ENetMultiplayerPeer peer)
{
_peer = peer; var api = new SceneMultiplayer { AllowObjectDecoding = false, ServerRelay = false }; _api = api;
_rootPath = GetPath(); GetTree().SetMultiplayer(api, _rootPath); api.MultiplayerPeer = peer;
_pendingStop = _publishPending = false; Connected = false; LastFailure = ""; _nextInput = 0;
Players = ReadyPlayers = SnapshotSequence = AcceptedRequests = RejectedRequests = LastSenderId = 0;
_ready.Clear(); _lastInput.Clear();
api.ConnectedToServer += () => { if (_api == api) { Connected = true; Joined?.Invoke(); } };
api.ConnectionFailed += () => { if (_api == api) { LastFailure = "connection-failed"; _pendingStop = true; } };
api.ServerDisconnected += () => { if (_api == api) { LastFailure = "server-disconnected"; _pendingStop = true; } };
api.PeerConnected += id => { if (_api == api && api.IsServer()) { _ready[(int)id] = false; _publishPending = true; } };
api.PeerDisconnected += id => { if (_api == api && api.IsServer()) { _ready.Remove((int)id); _lastInput.Remove((int)id); _publishPending = true; } };
}
public Error RequestReady(bool ready)
{
if (!Connected || _api is null) return Error.Unconfigured;
if (IsServer) { _ready[1] = ready; _publishPending = true; return Error.Ok; }
return RpcId(1, nameof(SubmitReady), ++_nextInput, ready);
}
[Rpc(MultiplayerApi.RpcMode.AnyPeer, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable, TransferChannel = 1)]
private void SubmitReady(int sequence, bool ready)
{
if (_api is null || !_api.IsServer()) { RejectedRequests++; return; }
int sender = _api.GetRemoteSenderId(); LastSenderId = sender;
int previous = _lastInput.GetValueOrDefault(sender);
if (sender <= 1 || !_ready.ContainsKey(sender) || sequence <= previous || sequence > previous + 64)
{ RejectedRequests++; return; }
_lastInput[sender] = sequence; _ready[sender] = ready; AcceptedRequests++; _publishPending = true;
}
private void Publish()
{
Players = _ready.Count; ReadyPlayers = 0;
foreach (bool ready in _ready.Values) if (ready) ReadyPlayers++;
SnapshotSequence++;
if (_api?.GetPeers().Length > 0) Rpc(nameof(ApplySnapshot), SnapshotSequence, ReadyPlayers, Players);
}
[Rpc(MultiplayerApi.RpcMode.Authority, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable, TransferChannel = 1)]
private void ApplySnapshot(int sequence, int ready, int players)
{
if (_api is null || _api.IsServer() || _api.GetRemoteSenderId() != 1 ||
sequence <= SnapshotSequence || ready < 0 || players < 1 || players > 5 || ready > players)
{ RejectedRequests++; return; }
SnapshotSequence = sequence; ReadyPlayers = ready; Players = players;
}
public override void _Process(double delta)
{
if (_pendingStop) { Stop(); return; }
if (_publishPending && IsServer) { _publishPending = false; Publish(); }
}
public void Stop()
{
Connected = false; _pendingStop = _publishPending = false;
if (_api is not null)
{
_api.MultiplayerPeer = null!;
if (IsInsideTree() && _rootPath is not null) GetTree().SetMultiplayer(null!, _rootPath);
_api.Dispose(); _api = null;
}
_peer?.Close(); _peer?.Dispose(); _peer = null; _rootPath?.Dispose(); _rootPath = null;
_ready.Clear(); _lastInput.Clear();
}
public override void _ExitTree() => Stop();
}В одном окне работают три независимых SceneMultiplayer с настоящими UDP-соединениями. Для каждого LobbySession задаётся собственный корень через EntityTree.SetMultiplayer. Поэтому каждый участник получает свои ID, соединение и RPC-контекст. Код не подменяет сетевой обмен общей коллекцией для всех клиентов. Отдельный тест запускает неизменённый LobbySession в серверном и двух клиентских процессах движка.
ServerRelay явно установлен false: этот протокол передаёт только запросы серверу и снимки от сервера. Клиентское Players приходит из игрового снимка, а не считается по клиентскому GetPeers. Связь клиент → клиент этим лобби не используется.
Кто изменяет состояние
| Шаг | Участник | Действие |
|---|---|---|
| RequestReady | Клиент | Посылает номер запроса и желаемую готовность серверу с ID 1 |
| SubmitReady | Сервер | Берёт настоящий sender ID из MultiplayerApi, проверяет членство и последовательность |
| Publish | Сервер | Пересчитывает игроков/готовых и увеличивает SnapshotSequence |
| ApplySnapshot | Клиент | Принимает допустимый снимок только от authority, сервера 1 |
В запросе нет доверенного «ID игрока» из пользовательского поля. GetRemoteSenderId читается внутри обработчика RPC. Перед отложенной/асинхронной работой сохраните этот ID: вне контекста текущего RPC он не обозначает прежнего отправителя.
AnyPeer означает разрешённых сетевых вызывающих, а не «этот метод безопасен» или «выполняется только на сервере». SubmitReady дополнительно проверяет роль сервера. ApplySnapshot помечен Authority; при authority = 1 клиент не может прислать свой снимок вместо серверного. В проверке такая попытка отклоняется движком.
Порядок и отключение
Номер входного запроса и SnapshotSequence — разные счётчики, а не версия движка. Сервер отвергает номер не выше предыдущего и слишком большой скачок; клиент принимает только более новый снимок. В примере разрешён шаг до 64, что является правилом этого лобби, а не обязательным правилом ENet.
Изменения членства и готовности ставят флаг рассылки. _Process рассылает один итог после обработки сетевых событий, вместо немедленного RPC из каждого PeerDisconnected. Это важно при одновременном выходе нескольких клиентов: попытка отправить пакет соединению, которое уже закрывается, не должна быть частью очистки.
При выходе удаляются и готовность, и последний номер запроса участника. Повторное подключение получает текущее состояние и начинает входную последовательность заново. Peer ID временный; не используйте его как постоянный ключ аккаунта/сохранения.
Stop освобождает только принадлежащие этому LobbySession peer и override API. Изменение таблицы API откладывается из сетевого события в _Process. При удалении Entity _ExitTree вызывает Stop. Управление MultiplayerApi и деревом выполняется в главном потоке.
RPC, каналы и структура
Обе стороны должны иметь согласованные методы RPC, сигнатуры, атрибуты и относительные пути от сетевого корня. Здесь один и тот же LobbySession.cs используется для всех участников. Имена внешних ветвей Host/First/Second различаются, но каждый API рассматривает свой LobbySession как корень.
Игровые RPC идут Reliable по каналу 1. Сервер и клиенты создают два пользовательских канала; номер 1 допустим. Reliable помогает с доставкой/порядком внутри живого соединения, но не является долговременным сохранением результата после разрыва. Для частых состояний движения отдельно выбирайте режим/канал, частоту и политику потерь; надёжный поток большого сообщения не нужен для каждого положения персонажа.
EntityTree обычно опрашивает MultiplayerApi автоматически. Если вы отключили MultiplayerPoll, организуйте Poll сами; не выполняйте одновременно две несогласованные схемы. Обычный вызов C#-метода не заменяет сетевой Rpc/RpcId.
Назначение authority не является ни проверкой аккаунта, ни Entity.Owner для редакторского сохранения. Если меняете authority, согласуйте значение на участниках; SetMultiplayerAuthority само по себе не рассылает все ваши игровые правила. Спавн префабов/Entity и репликация свойств требуют отдельной настройки MultiplayerSpawner/MultiplayerSynchronizer; этот пример их не включает.
Границы примера
Сервер привязан к 127.0.0.1, принимает до четырёх удалённых клиентов; вместе с сервером максимум пять участников. CreateClient начинает соединение, а не подтверждает вход. ConnectedToServer, ConnectionFailed и ServerDisconnected описывают дальнейший исход. Для внешнего сервера нужны адрес, UDP-маршрут и отдельные сетевые проверки.
AllowObjectDecoding оставлен false. Лобби не реализует учётные записи, шифрование ENet-трафика, защиту от потока запросов, подбор игроков, восстановление матча, prediction, rollback или синхронизацию физики. Оно проверяет небольшой серверный протокол: действительный отправитель, последовательность, authority и очистка при отключении. Тесты охватывают локальные независимые API и три отдельных процесса; WAN/NAT, другие устройства и готовый экспорт не проверены.
Известный отдельный сбой проверенной сборки: с обычным ServerRelay = true при одновременном выходе двух процессов зафиксирована нативная ошибка Unable to send packet on channel 0, max channels: 0. Она сохранена как воспроизводившийся дефект, а не исправлена в движке. Проверка лобби с ServerRelay = false не доказывает исправность этого режима ретрансляции; включение relay требует своей проверки.