reckfellENGINE

Мультиплеер ENet: лобби, RPC и серверное состояние

Готовое лобби с сервером и двумя клиентами: отправитель, права RPC, повторы и отключение.

На этой странице

Запустить лобби

Добавьте LobbySession.cs и LobbyLab.cs в C#-проект 1.0.0. Создайте отдельный уровень с корнем Control, прикрепите LobbyLab, соберите и нажмите F6. Окно — 640×420 или больше. Сначала сервер и оба клиента должны показать 3 игрока и 0 готовых; сервер входит в число игроков.

Первая и вторая кнопки переключают готовность соответствующего клиента. После двух нажатий все три участника показывают 3 / 2. Третья кнопка отключает и снова подключает второго клиента. Отключённый клиент помечается явно, а сервер удаляет его готовность.

Лобби после отметки двух готовых клиентов

LobbyLab.cs
C#
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
C#
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 требует своей проверки.

Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.