Сервер Игры на 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();             }         }     } 

ссылка на оригинал статьи https://habr.com/ru/post/499556/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *