Сервер Игры на MS Orleans — часть 3: Итоги



    Привет, Хабр! Я продолжаю изучать 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();
                }
            }
        }
    
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое