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