Привет, Хабр! Я продолжаю изучать MS Orleans и делать простенькую онлайн игру с консольным клиентом и сервером работающим с Orleans грейнами. На этот раз я расскажу чем все закончилось и какие я для себя выводы сделал. За подробностями добро пожаловать под кат.

Таки да, если вам интересно вообще как игровые сервера для динамических игр делаются, а не мой эксперимент с MS Orleans то рекомендую глянуть этот репозиторий (UDP) и эти статьи почитать:

  1. habr.com/ru/post/303006
  2. habr.com/ru/post/328118
  3. habr.com/ru/company/pixonic/blog/499642
  4. habr.com/ru/company/pixonic/blog/420019

Содержание



Исходники


MsOrleansOnlineGame

Об игре


Получилась простенькая стрелялка. Зеленые # это противники. Желтый # это ваш персонаж. Красный $ это пуля. Стрельба ведется в том направлении куда вы идете. Направление движения регулируется кнопками W A S D или стрелочками. Для выстрела предназначена клавиша пробела. Подробно описывать код клиента не вижу смысла потому что его нужно заменить на нормальный. Графический.

Oб акторах (грейнах)


Если кратко: Мое ИМХО что Орлеанс это gRPC на стероидах заточенный под Azure, масштабирование и работу с ин мемори стейтом. С кешем например. Хотя и без стейта как обычный RPC через Stateless Worker Grains умеет он работать. Грейн (Актор) в Орлеанс может выступать в роли точки входа как Controller в Asp.Net. Но в отличии от Контроллера у грейна один единственный инстанс у которого есть свой идентификатор. Грейны хороши тогда когда вам из нескольких потоков или от нескольких пользователей надо одновременно работать с каким-то состоянием. Они обеспечивают потокобезопасную работу с ним.

Например вот актор для корзины товаров. При первом вызове он будет создан и будет висеть в памяти играя роль кеша. При этом к нему могут одновременно делать запросы и на добавление и удаление предметов тысячи пользователей из тысячи разных потоков. Вся работа с его состоянием внутри него будет абсолютно потокобезопасной. При этом конечно было бы полезно сделать актор Shop у которого будет метод List GetBaskets() чтобы получать список всех доступных в системе корзин. При этом Shop тоже будет висеть в памяти как кеш и вся работа с ним будет потокобезопасной.

    public interface IBasket : IGrainWithGuidKey
    {
        Task Add(string item);
        Task Remove(string item);
        Task<List<string>> GetItems();
    }

    public class BasketGrain : Grain, IBasket
    {
        private readonly ILogger<BasketGrain> _logger;
        private readonly IPersistentState<List<string>> _store;

        public BasketGrain(
            ILogger<BasketGrain> logger,
            [PersistentState("basket", "shopState")] IPersistentState<List<string>> store
        )
        {
            _logger = logger;
            _store = store;
        }

         public override Task OnActivateAsync()
        {
             var shop = GrainFactory.GetGrain<IShop>();
           //Добавляем в список корзин нашу если ее еще нет в списке.
            await shop.AddBasketIfNotContains(this.GetPrimaryKey())
            return base.OnActivateAsync();
        }

        public override async Task OnDeactivateAsync()
        {
          //Орлеанс автоматически активирует грейны когда мы их вызываем
         // Так же как Asp.Net создает контроллеры. 
        // В отличии от контроллера грейн висит в памяти пока его кто-то использует.
        // Если его долго ник-то не вызывает то Орлеанс убивает грейн.
        //Перед тем как это сделать вызывается автоматически этот стандартный метод.
     // Тут мы записываем состояние нашего грейна в БД
            await _store.WriteStateAsync();
            await base.OnDeactivateAsync();
        }


        public Task Add(string item)
        {
            _store.State.Add(item);
            return Task.CompletedTask;
        }

        public Task Remove(string item)
        {
            _store.State.Remove(item);
            return Task.CompletedTask;
        }

        public Task<List<string>> GetItems()
        {
           //Грейны сериализуют отправляемые и десереализуют принимаемые значения.
          // Поэтому лучше из грейна возвращать копию его состояния
         // Чтобы во время сериализации не выскочила ошибка ака Коллекшн хаз чейнджед
            return Task.FromResult(new List<string>(_store.State));
        }
    }

Пример использования в каком нибудь консольном приложении:

         private static async Task DoClientWork(IClusterClient client, Guid baskeId)
        {
            var basket = client.GetGrain<IBasket>(baskeId);
           //как и с gRPC - на самом деле это действие отправит запрос на сервер где и произойдет добавление строки в список
            await basket.Add("Apple");
        }

Код игры



Карта на которой сражаются игроки:

   public interface IFrame : IGrainWithIntegerKey
    {
        Task Update(Frame frame);
        Task<Frame> GetState();
    }

    public class FrameGrain : Grain, IFrame
    {
        private readonly ILogger<FrameGrain> _logger;
        private readonly IPersistentState<Frame> _store;

        public FrameGrain(
            ILogger<FrameGrain> logger,
            [PersistentState("frame", "gameState")] IPersistentState<Frame> store
        )
        {
            _logger = logger;
            _store = store;
        }

        public override Task OnActivateAsync()
        {
            _logger.LogInformation("ACTIVATED");
           //Связь игры и карты 1 к 1 поэтому айди карты и игры одинаковы.
            _store.State.GameId = this.GetPrimaryKeyLong();
            return base.OnActivateAsync();
        }

        public override async Task OnDeactivateAsync()
        {
            _logger.LogInformation("DEACTIVATED");
            await _store.WriteStateAsync();
            await base.OnDeactivateAsync();
        }

        public Task Update(Frame frame)
        {
            _store.State = frame;
            return Task.CompletedTask;
        }

        public Task<Frame> GetState() => Task.FromResult(_store.State.Clone());
    }

Грейн игры который хранит общее состояние текущей игры и 20 раз в секунду отправляет его клиенту по SignalR.

    public interface IGame : IGrainWithIntegerKey
    {
        Task Update(Player player);
        Task Update(Bullet bullet);
        Task<List<Player>> GetAlivePlayers();
    }

    public class GameGrain : Grain, IGame
    {
        private const byte WIDTH = 100;
        private const byte HEIGHT = 50;
        private readonly ILogger<GameGrain> _logger;
        private readonly IPersistentState<Game> _store;
        private readonly IHubContext<GameHub> _hub;
        private IDisposable _timer;
        public GameGrain(
            ILogger<GameGrain> logger,
            [PersistentState("game", "gameState")] IPersistentState<Game> store,
            IHubContext<GameHub> hub
            )
        {
            _logger = logger;
            _store = store;
            _hub = hub;
        }

        public override async Task OnActivateAsync()
        {
            _store.State.Id = this.GetPrimaryKeyLong();
            _store.State.Frame = new Frame(WIDTH, HEIGHT) { GameId = _store.State.Id };
            var frame = GrainFactory.GetGrain<IFrame>(_store.State.Id);
            await frame.Update(_store.State.Frame.Clone());
            _logger.LogWarning("ACTIVATED");
            //Тут происходит регистрация таймера который каждые 50 миллисекунд будет дергать метод нашего грейна. Это метод отправляет текущее состояние игры клиенту.
            _timer = RegisterTimer(Draw, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(50));
            await base.OnActivateAsync();
        }

        public override async Task OnDeactivateAsync()
        {
            _logger.LogWarning("DEACTIVATED");
            _timer?.Dispose();
            _timer = null;
            await _store.WriteStateAsync();
            await base.OnDeactivateAsync();
        }

        public async Task Draw(object obj)
        {
            var state = _store.State;
            state.Bullets.RemoveAll(b => !b.IsAlive);
            state.Players.RemoveAll(p => !p.IsAlive);
            try
            {
                await _hub.Clients.All.SendAsync("gameUpdated", state.Clone());
            }
            catch (Exception e)
            {
                _logger.LogError(e, "Error on send s");
            }
        }

        public Task Update(Player player)
        {
            _store.State.Players.RemoveAll(x => x.Id == player.Id);
            _store.State.Players.Add(player);
            return Task.CompletedTask;
        }
        public Task Update(Bullet bullet)
        {
            _store.State.Bullets.RemoveAll(x => x.Id == bullet.Id);
            _store.State.Bullets.Add(bullet);
            return Task.CompletedTask;
        }

        public Task<List<Player>> GetAlivePlayers() =>
            Task.FromResult(_store.State.Players.Where(p => p.IsAlive).Select(p => p.Clone()).ToList());
    }

SignalR хаб через который мы общаемся с клиентом. Он выступает в роли прокси между WebGl клиентом и Orleans. Пока что клиент консольный и он дико стремный. Я хочу сделать в будущем веб клиент игры в браузере на Three.js и поэтому нужно подключение по вебсокету SignalR. Сам Orleans клиент только на C# в отличии от gRPC которые доступен на многих языках поэтому для веб клиентом между сервером Orleans и клиентами надо ставить прокси (Gateway asp.net core).

    public class GameHub : Hub
    {
        private readonly IGrainFactory _client;

        public GameHub(IGrainFactory client)
        {
            _client = client;
        }

        public async Task GameInput(Input input)
        {
            var player = _client.GetGrain<IPlayer>(input.PlayerId);
            await player.Handle(input);
        }
    }

Грейн игрока. Он автоматически по таймеру движется и реагирует на команды пользователя. Если приходит команда стрелять то он создает грейн пули и устанавливает для него направление движения.

    public class PlayerGrain : Grain, IPlayer
    {
        private readonly ILogger<PlayerGrain> _logger;
        private readonly IPersistentState<Player> _store;
        private IDisposable _timer;
        private readonly Queue<Input> _inputs;
        public PlayerGrain(
            ILogger<PlayerGrain> logger,
            [PersistentState("player", "gameState")] IPersistentState<Player> store
        )
        {
            _logger = logger;
            _store = store;
            _inputs = new Queue<Input>();
        }

        public override Task OnActivateAsync()
        {
            _logger.LogInformation("ACTIVATED");
            // State это просто POCO класс с геттерами и сеттерами. Entity Player в нашем случае
            _store.State.Id = this.GetPrimaryKey();
            _timer = RegisterTimer(Update, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(200));
            return base.OnActivateAsync();
        }

        public override async Task OnDeactivateAsync()
        {
            _logger.LogInformation("ACTIVATED");
            _timer?.Dispose();
            _timer = null;
            await _store.WriteStateAsync();
            await base.OnDeactivateAsync();
        }

        public async Task Handle(Input input)
        {
            _store.State.GameId = input.GameId;
            _inputs.Enqueue(input);
        }

        public async Task Update(object obj)
        {
            if (!_store.State.IsAlive)
            {
                await _store.ClearStateAsync();
               //Говорим серверу Орлеас что можно удалить этот грейн из оперативной памяти.
             // потому что он нам больше не нужен. Это произойдет после выхода из этого метода.
                DeactivateOnIdle();
                return;
            }

            while (_inputs.Count > 0)
            {
                var input = _inputs.Dequeue();
                foreach (var direction in input.Directions.Where(d => d != Direction.None))
                {
                    _store.State.Direction = direction;
                }

                foreach (var command in input.Commands.Where(c => c != Command.None))
                {
                    if (command == Command.Shoot)
                    {
                        var bulletId = Guid.NewGuid();
                        var bullet = GrainFactory.GetGrain<IBullet>(bulletId);
                        // Метод Shot() просто возвращает направление куда смотрит игрок и место где он стоит.
                        bullet.Update(_store.State.Shot()).Ignore(); //Ignore() эвейтит таску и игнорирует ошибку если она возникает
                    }
                }
            }
            _store.State.Move();
            if (_store.State.GameId.HasValue)
            {
                var frame = GrainFactory.GetGrain<IFrame>(_store.State.GameId.Value);
                var fs = await frame.GetState();
                if (fs.Collide(_store.State))
                    _store.State.MoveBack();
                GrainFactory.GetGrain<IGame>(_store.State.GameId.Value)
                    .Update(_store.State.Clone())
                    .Ignore();
            }
        }

        public async Task Die()
        {
            _store.State.IsAlive = false;
            if (_store.State.GameId.HasValue)
                await GrainFactory.GetGrain<IGame>(_store.State.GameId.Value).Update(_store.State.Clone());
            await _store.ClearStateAsync();
            DeactivateOnIdle();
        }
    }


Грейн пули. Она автоматически движется по таймеру и если сталкивается с игроком то приказывает ему умереть. Если сталкивается с препятствием на карте то умирает сама.
  public interface IBullet : IGrainWithGuidKey
    {
        Task Update(Bullet dto);
    }

    public class BulletGrain : Grain, IBullet
    {
        private readonly ILogger<BulletGrain> _logger;
        private readonly IPersistentState<Bullet> _store;
        private IDisposable _timer;
        public BulletGrain(
            ILogger<BulletGrain> logger,
            [PersistentState("bullet", "gameState")] IPersistentState<Bullet> store
        )
        {
            _logger = logger;
            _store = store;
        }

        public Task Update(Bullet dto)
        {
            _store.State = dto;
            _store.State.Id = this.GetPrimaryKey();
            return Task.CompletedTask;
        }

        public override Task OnActivateAsync()
        {
            _logger.LogInformation("ACTIVATED");
            _timer = this.RegisterTimer(Update, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(50));
            return base.OnActivateAsync();
        }

        public override async Task OnDeactivateAsync()
        {
            _logger.LogInformation("DEACTIVATED");
            _timer?.Dispose();
            _timer = null;
            await _store.WriteStateAsync();
            await base.OnDeactivateAsync();
        }

        public async Task Update(object obj)
        {
            if (!_store.State.IsAlive)
            {
                await _store.ClearStateAsync();
                DeactivateOnIdle();
                return;
            }
            _store.State.Move();
            if (_store.State.GameId.HasValue)
            {
                var frame = GrainFactory.GetGrain<IFrame>(_store.State.GameId.Value);
                var fs = await frame.GetState();
                if (fs.Collide(_store.State))
                    _store.State.IsAlive = false;
                if (_store.State.Point.X > fs.Width || _store.State.Point.Y > fs.Height)
                    _store.State.IsAlive = false;
                var game = GrainFactory.GetGrain<IGame>(_store.State.GameId.Value);
                var players = await game.GetAlivePlayers();
                foreach (var player in players)
                {
                    if (player.Collide(_store.State))
                    {
                        _store.State.IsAlive = false;
                        GrainFactory.GetGrain<IPlayer>(player.Id).Die().Ignore();
                        break;
                    }
                }
                game.Update(_store.State.Clone()).Ignore();
            }
        }
    }