Привет, Хабр! Решил я значит попробовать переписать тот сервер что делал с MS Orleans на Akka.NET просто чтобы попробовать и эту технологию тоже. Если вам интересно что получилось до добро пожаловать под кат.
gitlab.com/VictorWinbringer/msorleansonlinegame/-/tree/master/Server/AkkaActors
Стрелялка с режимом дес матч. Все против всех. Зеленые # это противники. Желтый # это ваш персонаж. Красный $ это пуля. Стрельба ведется в том направлении куда вы движетесь.
Направление движения регулируется кнопками W A S D или стрелочками. Для выстрела предназначена клавиша пробела. Хочу сделать в будущем графический клиент на Three.js и выложить игру на какой нибудь бесплатный хостинг. Пока что есть только временный консольный клиент.
В общем то они оба решают проблему когда вы хотите распаралелить свои вычисления и при этом не использовать lock(object). Грубо говоря весь код который у вас находиться внутри lock обычно можно поместить в актор. Кроме этого каждый актор живет своей жизнью и его независимо от других можно перезапустить. При этом сохраняя жизнеспособность всей системы в целом. Отказоустойчивость в общем. MS Orleans мне показался более удобный и заточенным под RPC. Akka.NET проще и меньше. Его можно просто как библиотеку для реактивный асинхронных вычислений использовать. MS Orleans сразу требует себе выделить отдельный порт и настроить для себя хост который будет запускаться при старте приложения. Akka.NET же в базовой комплектации ничего не надо. Подключил nuget пакет и пользуешься. Зато у MS Orleans строго типизированные интерфейсы для акторов(грейнов). В целом если бы мне нужно было написать микросервис целиком на акторах то я выбрал бы MS Orleans если же просто в одном месте распаралелить вычисления и избежать синхронизации потоков через lock, AutoResetEventSlim или еще что-то в этом роде то Akka.NET. Таки да, бытует заблуждение якобы сервер стрелялки Hallo сделан на акторах. Ой таки вей. Там на акторах только всякая инфраструктура вроде платежей и прочего. Сама логика движения игрока и попадания выстрела, тобишь игровая логика, она в C++ монолите вычисляется. Вот в MMO вроде WoW где где вы выбираете явно цель и у вас есть глобальная перезарядка размером в почти 1 секунду на все заклинания там часто используют акторы.
Регистрация нашей актор системы в DI:
Исходники
gitlab.com/VictorWinbringer/msorleansonlinegame/-/tree/master/Server/AkkaActors
Об игре
Стрелялка с режимом дес матч. Все против всех. Зеленые # это противники. Желтый # это ваш персонаж. Красный $ это пуля. Стрельба ведется в том направлении куда вы движетесь.
Направление движения регулируется кнопками W A S D или стрелочками. Для выстрела предназначена клавиша пробела. Хочу сделать в будущем графический клиент на Three.js и выложить игру на какой нибудь бесплатный хостинг. Пока что есть только временный консольный клиент.
Личные впечатления
В общем то они оба решают проблему когда вы хотите распаралелить свои вычисления и при этом не использовать lock(object). Грубо говоря весь код который у вас находиться внутри lock обычно можно поместить в актор. Кроме этого каждый актор живет своей жизнью и его независимо от других можно перезапустить. При этом сохраняя жизнеспособность всей системы в целом. Отказоустойчивость в общем. MS Orleans мне показался более удобный и заточенным под RPC. Akka.NET проще и меньше. Его можно просто как библиотеку для реактивный асинхронных вычислений использовать. MS Orleans сразу требует себе выделить отдельный порт и настроить для себя хост который будет запускаться при старте приложения. Akka.NET же в базовой комплектации ничего не надо. Подключил nuget пакет и пользуешься. Зато у MS Orleans строго типизированные интерфейсы для акторов(грейнов). В целом если бы мне нужно было написать микросервис целиком на акторах то я выбрал бы MS Orleans если же просто в одном месте распаралелить вычисления и избежать синхронизации потоков через lock, AutoResetEventSlim или еще что-то в этом роде то Akka.NET. Таки да, бытует заблуждение якобы сервер стрелялки Hallo сделан на акторах. Ой таки вей. Там на акторах только всякая инфраструктура вроде платежей и прочего. Сама логика движения игрока и попадания выстрела, тобишь игровая логика, она в C++ монолите вычисляется. Вот в MMO вроде WoW где где вы выбираете явно цель и у вас есть глобальная перезарядка размером в почти 1 секунду на все заклинания там часто используют акторы.
Код и коментарии
Входная точка нашего сервера. SignalR Hub
public class GameHub : Hub
{
private readonly ActorSystem _system;
private readonly IServiceProvider _provider;
private readonly IGrainFactory _client;
public GameHub(
IGrainFactory client,
ActorSystem system,
IServiceProvider provider
)
{
_client = client;
_system = system;
_provider = provider;
}
public async Task JoinGame(long gameId, Guid playerId)
{
//IActorRef это прокси к нашему актору.
// Он передает полученные сообщения актору на который ссылается
var gameFactory = _provider.GetRequiredServiceByName<Func<long, IActorRef>>("game");
var game = gameFactory(gameId);
var random = new Random();
var player = new Player()
{
IsAlive = true,
GameId = gameId,
Id = playerId,
Point = new Point()
{
X = (byte)random.Next(1, GameActor.WIDTH - 1),
Y = (byte)random.Next(1, GameActor.HEIGHT - 1)
}
};
game.Tell(player);
}
public async Task GameInput(Input input, long gameId)
{
// Путь к актору состоит из имени его родителя и его собственного имения.
// user это все пользовательские акторы.
// целиком это будет что-то типо akka://game/user/1/2
_system.ActorSelection($"user/{gameId}/{input.PlayerId}").Tell(input);
}
}
Регистрация нашей актор системы в DI:
services.AddSingleton(ActorSystem.Create("game"));
var games = new Dictionary<long, IActorRef>();
services.AddSingletonNamedService<Func<long, IActorRef>>(
"game", (sp, name) => gameId =>
{
lock (games)
{
if (!games.TryGetValue(gameId, out IActorRef gameActor))
{
var frame = new Frame(GameActor.WIDTH, GameActor.HEIGHT) { GameId = gameId };
var gameEntity = new Game()
{
Id = gameId,
Frame = frame
};
//Фабрика для создания инстансов актора.
// Передаем туда IServiceProvide чтобы актор мог резолвить нужные ему зависимости
var props = Props.Create(() => new GameActor(gameEntity, sp));
var actorSystem = sp.GetRequiredService<ActorSystem>();
//Создаем новый актор если такой мы еще не запускали.
gameActor = actorSystem.ActorOf(props, gameId.ToString());
games[gameId] = gameActor;
}
return gameActor;
}
});
Акторы
GameActor
public sealed class GameActor : UntypedActor
{
public const byte WIDTH = 100;
public const byte HEIGHT = 50;
private DateTime _updateTime;
private double _totalTime;
private readonly Game _game;
private readonly IHubContext<GameHub> _hub;
public GameActor(Game game, IServiceProvider provider)
{
_updateTime = DateTime.Now;
_game = game;
_hub = (IHubContext<GameHub>)provider.GetService(typeof(IHubContext<GameHub>));
//Запускаем шедулер который буде постоянно отправлять нашему актору сообщение
//RunMessage говорящее ему пробежать один игровой цикл.
//Обновление и оправка нового состояния на клиент.
Context
.System
.Scheduler
.ScheduleTellRepeatedly(
//Сколько надо подождать прежде чем начать отсылать сообщения.
TimeSpan.FromMilliseconds(100),
//Раз в сколько миллисекунд отправлять сообщения
TimeSpan.FromMilliseconds(1),
//Получатель сообщения
Context.Self,
//Что за сообщение отправлять получателю
new RunMessage(),
//Кто является отправителем сообщения. Nobody значит что никто. null тобишь.
ActorRefs.Nobody
);
}
//Основная точка входа нашего актора.
//Срабатывает когда актор получает какое-то сообщение снаружи.
protected override void OnReceive(object message)
{
if (message is RunMessage run)
Handle(run);
if (message is Player player)
Handle(player);
if (message is Bullet bullet)
Handle(bullet);
}
//Работает по принципу Create or Update
//Если игровая сущность мертва то останавливает ее актор и удаляет ее из списка
private void Update<T>(
List<T> entities,
T entity,
Func<object> createInitMessage,
Func<Props> createProps
)
where T : IGameEntity
{
if (!entity.IsAlive)
{
var actor = Context.Child(entity.Id.ToString());
if (!actor.IsNobody())
Context.Stop(actor);
entities.RemoveAll(b => b.Id == entity.Id);
}
//Create
else if (!entities.Any(b => b.Id == entity.Id))
{
Context.ActorOf(createProps(), entity.Id.ToString());
entities.Add(entity);
Context.Child(entity.Id.ToString()).Tell(createInitMessage());
}
//Update
else
{
entities.RemoveAll(b => b.Id == entity.Id);
entities.Add(entity);
}
}
private void Handle(Bullet bullet)
{
Update(
_game.Bullets,
bullet,
() => new InitBulletMessage(bullet.Clone(), _game.Frame.Clone()),
() => Props.Create(() => new BulletActor())
);
}
private void Handle(Player player)
{
Update(
_game.Players,
player,
() => new InitPlayerMessage(player.Clone(), _game.Frame.Clone()),
() => Props.Create(() => new PlayerActor())
);
}
private void Handle(RunMessage run)
{
var deltaTime = DateTime.Now - _updateTime;
_updateTime = DateTime.Now;
var delta = deltaTime.TotalMilliseconds;
Update(delta);
Draw(delta);
}
private void Update(double deltaTime)
{
var players = _game.Players.Select(p => p.Clone()).ToList();
foreach (var child in Context.GetChildren())
{
child.Tell(new UpdateMessage(deltaTime, players));
}
}
private void Draw(double deltaTime)
{
_totalTime += deltaTime;
if (_totalTime < 50)
return;
_totalTime = 0;
//PipeTo отправляем результат работы Task этому актору в виде сообщения.
//Для ReciveActor можно просто стандартный async await использовать
_hub.Clients.All.SendAsync("gameUpdated", _game.Clone()).PipeTo(Self);
}
}
BulletActor
public class BulletActor : UntypedActor
{
private Bullet _bullet;
private Frame _frame;
protected override void OnReceive(object message)
{
if (message is InitBulletMessage bullet)
Handle(bullet);
if (message is UpdateMessage update)
Handle(update);
}
private void Handle(InitBulletMessage message)
{
_bullet = message.Bullet;
_frame = message.Frame;
}
private void Handle(UpdateMessage message)
{
if (_bullet == null)
return;
if (!_bullet.IsAlive)
{
Context.Parent.Tell(_bullet.Clone());
return;
}
_bullet.Move(message.DeltaTime);
if (_frame.Collide(_bullet))
_bullet.IsAlive = false;
if (!_bullet.IsInFrame(_frame))
_bullet.IsAlive = false;
foreach (var player in message.Players)
{
if (player.Id == _bullet.PlayerId)
continue;
//Если пуля сталкивается с игроком то она говорит ему умереть
//и умирает сама
if (player.Collide(_bullet))
{
_bullet.IsAlive = false;
Context
.ActorSelection(Context.Parent.Path.ToString() + "/" + player.Id.ToString())
.Tell(new DieMessage());
}
}
Context.Parent.Tell(_bullet.Clone());
}
}
PlayerActor
public class PlayerActor : UntypedActor
{
private Player _player;
private Queue<Direction> _directions;
private Queue<Command> _commands;
private Frame _frame;
public PlayerActor()
{
_directions = new Queue<Direction>();
_commands = new Queue<Command>();
}
protected override void OnReceive(object message)
{
if (message is Input input)
Handle(input);
if (message is UpdateMessage update)
Handle(update);
if (message is InitPlayerMessage init)
Handle(init);
if (message is DieMessage)
{
_player.IsAlive = false;
Context.Parent.Tell(_player.Clone());
}
}
private void Handle(InitPlayerMessage message)
{
_player = message.Player;
_frame = message.Frame;
}
private void Handle(Input message)
{
if (_player == null)
return;
if (_player.IsAlive)
{
foreach (var command in message.Commands)
{
_commands.Enqueue(command);
}
foreach (var direction in message.Directions)
{
_directions.Enqueue(direction);
}
}
}
private void Handle(UpdateMessage update)
{
if (_player == null)
return;
if (_player.IsAlive)
{
HandleCommands(update.DeltaTime);
HandleDirections();
Move(update.DeltaTime);
}
Context.Parent.Tell(_player.Clone());
}
private void HandleDirections()
{
while (_directions.Count > 0)
{
_player.Direction = _directions.Dequeue();
}
}
private void HandleCommands(double delta)
{
_player.TimeAfterLastShot += delta;
if (!_player.HasColldown && _commands.Any(command => command == Command.Shoot))
{
//Shot просто фабричный метод который создает пулю
// которая движется в том направлении куда смотри персонаж игрока
var bullet = _player.Shot();
Context.Parent.Tell(bullet.Clone());
_commands.Clear();
}
}
private void Move(double delta)
{
_player.Move(delta);
if (_frame.Collide(_player))
_player.MoveBack();
}
}
Сообщения пересылаемые между акторами
//Просто приказывает персонажу игрока умереть.
//Если он вдруг в этот момент неуязвим то он может проигнорировать это сообщение.
public sealed class DieMessage { }
//Инициализирует актор управляющей состоянием пули начальным значениями
//Точнее говорит актору чтобы он себя инициализировал этими значениями
public sealed class InitBulletMessage
{
public Bullet Bullet { get; }
public Frame Frame { get; }
public InitBulletMessage(Bullet bullet, Frame frame)
{
Bullet = bullet ?? throw new ApplicationException("Укажите пулю");
Frame = frame ?? throw new ApplicationException("Укажите фрейм");
}
}
//Говорит актору управляющему состоянием персонажа игрока
//воспользоваться этими значениями для своей инициализации
public class InitPlayerMessage
{
public Player Player { get; }
public Frame Frame { get; }
public InitPlayerMessage(Player player, Frame frame)
{
Player = player ?? throw new ApplicationException("Укажите игрока!");
Frame = frame ?? throw new ApplicationException("Укажите фрейм");
}
}
//Просто говорит актору управляющему всем состоянием игры пробежать один игровой цикл
public sealed class RunMessage { }
//Говорит актору пули обновится свое состояние
// с учетом прошедшего с последнего обновления времени.
public sealed class UpdateMessage
{
public double DeltaTime { get; }
//Нужны чтобы пуля могла проверить свое попадание в одного из них
public List<Player> Players { get; }
public UpdateMessage(double deltaTime, List<Player> players)
{
DeltaTime = deltaTime;
Players = players ?? throw new ApplicationException("Укажите игроков!");
}
}