Привет, Хабр! Я продолжаю изучать MS Orleans и делать простенькую онлайн игру с консольным клиентом и сервером работающим с Orleans грейнами. На этот раз я расскажу чем все закончилось и какие я для себя выводы сделал. За подробностями добро пожаловать под кат.
Таки да, если вам интересно вообще как игровые сервера для динамических игр делаются, а не мой эксперимент с MS Orleans то рекомендую глянуть этот репозиторий (UDP) и эти статьи почитать:
- habr.com/ru/post/303006
- habr.com/ru/post/328118
- habr.com/ru/company/pixonic/blog/499642
- habr.com/ru/company/pixonic/blog/420019
Содержание
- Сервер Игры на MS Orleans — часть 1: Что такое Акторы
- Сервер Игры на MS Orleans — часть 2: Делаем управляемую точку
- Сервер Игры на MS Orleans — часть 3: Итоги
Исходники
О игре
Получилась простенькая стрелялка. Зеленые # это противники. Желтый # это ваш персонаж. Красный $ это пуля. Стрельба ведется в том направлении куда вы идете. Направление движения регулируется кнопками 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/
Добавить комментарий