Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ DI необходим для того, чтобы всё это работало
Так как всё в этом мире завязано на "мир" (`World`), то мы начнём с его создания.
```csharp
var world = new WorldBuilder()
.DefaultParallelWorker(4) // чтобы обрабатывать сущности в 4 потока
.DebugWorld() // чтобы получать больше информации в Debug-режиме
.CreateLogger(logger => logger.UseConsoleSink()); // чтобы логи выводились в консоль
.Build();
.CreateLogger(logger => logger.UseConsoleSink()) // чтобы логи выводились в консоль
.Build();
```
Этот код создаёт сконфигурированный объект World. Так как библиотека AOT-friendly, тут нет сканеров assembly для регистрации отдельных компонентов. Всё нужно регистрировать вручную.

Expand All @@ -29,8 +30,8 @@ var world = new WorldBuilder()

```csharp
public struct FlyComponent(int current, int max) : IActorComponent { // необходимо указать маркерный интерфейс
public int CurrentSpeed;
public int MaxSpeed;
public int CurrentSpeed = current;
public int MaxSpeed = max;
}
```
Компоненты должны быть `struct`, чтобы всё в вашем мире могло работать эффективно и быстро.
Expand Down Expand Up @@ -125,4 +126,10 @@ Actor passenger = ...;
plane.AddChild(passenger);
```

Если повторить это много раз, то в самолёте будут сидеть 2-400 пассажиров (в зависимости от типа самолёта, конечно).
Если повторить это много раз, то в самолёте будут сидеть множество пассажиров (в зависимости от типа самолёта, конечно).

## Системы

У нас же ecS, а значит у нас есть системы.
Системы - это классы, которые обрабатывают компоненты актёров.
Они могут быть зарегистрированы в мире и запущены для обработки компонентов.
5 changes: 4 additions & 1 deletion src/Hexecs.Benchmarks.MonoGame/BenchmarkGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ protected override void Initialize()
_world = new WorldBuilder()
.DefaultParallelWorker(Math.Min(6, Environment.ProcessorCount))
.DefaultActorContext(builder => builder
// Передаем зависимости явно через фабрику или DI
.Capacity(InitialEntityCount)
.ConfigureComponentPool<CircleColor>(color => color.Capacity(InitialEntityCount))
.ConfigureComponentPool<Position>(position => position.Capacity(InitialEntityCount))
.ConfigureComponentPool<Velocity>(velocity => velocity.Capacity(InitialEntityCount))
.CreateUpdateSystem(ctx =>
new MovementSystem(ctx, ctx.GetRequiredService<IParallelWorker>(), width, height))
.CreateDrawSystem(ctx => new RenderSystem(ctx, GraphicsDevice, MaxEntityCount * 2)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ namespace Hexecs.Benchmarks.Actors;
// .NET SDK 10.0.101
// [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
// .NET 10.0 : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a

//
// Job=.NET 10.0 Runtime=.NET 10.0

// | Method | Mean | Ratio | Allocated | Alloc Ratio |
// |-------------------- |----------:|------:|----------:|------------:|
// | Hexecs_Parallel | 48.63 us | 1.00 | - | NA |
// | DefaultEcs_Parallel | 117.87 us | 2.42 | - | NA |
//
// | Method | Mean | Ratio | Allocated | Alloc Ratio |
// |-------------------- |-----------:|------:|----------:|------------:|
// | Hexecs_Parallel | 769.8 us | 1.00 | - | NA |
// | DefaultEcs_Parallel | 1,576.7 us | 2.05 | - | NA |

[SimpleJob(RuntimeMoniker.Net10_0)]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[MeanColumn, MemoryDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class UpdateSystemWithParallelWorkerBenchmark
{
private const int Count = 100_000;
private const int Count = 2_000_000;

private DefaultEcs.World _defaultWorld = null!;
private DefaultEcsParallelSystem _defaultSystem = null!;
Expand Down
4 changes: 2 additions & 2 deletions src/Hexecs.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using BenchmarkDotNet.Running;
using Hexecs.Benchmarks.Collections;
using Hexecs.Benchmarks.Actors;

BenchmarkRunner.Run<SparsePageDictionaryBenchmark>();
BenchmarkRunner.Run<UpdateSystemWithParallelWorkerBenchmark>();
134 changes: 134 additions & 0 deletions src/Hexecs.Tests/Actors/ActorConstraintShould.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using Hexecs.Tests.Mocks;

namespace Hexecs.Tests.Actors;

public sealed class ActorConstraintShould(ActorTestFixture fixture) : IClassFixture<ActorTestFixture>
{
[Fact(DisplayName = "Должен успешно проходить проверку Applicable, если все условия соблюдены")]
public void Should_Be_Applicable_When_Conditions_Met()
{
// Arrange
var actor = fixture.CreateActor<Description>(); // Создаем актора с компонентом
var constraint = ActorConstraint.Include<Description>(fixture.Actors).Build();

// Act
var result = constraint.Applicable(actor.Id);

// Assert
result.Should().BeTrue();
}

[Fact(DisplayName = "Должен возвращать false в Applicable, если компонент исключен")]
public void Should_Not_Be_Applicable_When_Excluded_Component_Exists()
{
// Arrange
var actor = fixture.CreateActor<Description>();
var constraint = ActorConstraint.Exclude<Description>(fixture.Actors).Build();

// Act
var result = constraint.Applicable(actor.Id);

// Assert
result.Should().BeFalse();
}

[Fact(DisplayName = "Должен вызывать событие Added, когда актор начинает соответствовать фильтру")]
public void Should_Raise_Added_When_Actor_Becomes_Valid()
{
// Arrange
var actor = fixture.CreateActor(); // Пустой актор
var constraint = ActorConstraint.Include<Description>(fixture.Actors).Build();

using var monitoredSubject = constraint.Monitor();

// Act
actor.Add(new Description()); // Теперь актор соответствует Include<Description>

// Assert
monitoredSubject.Should().Raise(nameof(constraint.Added))
.WithArgs<uint>(id => id == actor.Id);
}

[Fact(DisplayName = "Должен вызывать событие Removing, когда актор перестает соответствовать фильтру")]
public void Should_Raise_Removing_When_Actor_Becomes_Invalid()
{
// Arrange
var actor = fixture.CreateActor<Description>();
var constraint = ActorConstraint.Include<Description>(fixture.Actors).Build();

using var monitoredSubject = constraint.Monitor();

// Act
actor.Remove<Description>();

// Assert
monitoredSubject.Should().Raise(nameof(constraint.Removing))
.WithArgs<uint>(id => id == actor.Id);
}

[Fact(DisplayName = "Builder должен выбрасывать исключение при добавлении дублирующегося компонента")]
public void Builder_Should_Throw_On_Duplicate_Component()
{
// Arrange
var builder = ActorConstraint.Include<Description>(fixture.Actors);

// Act
var action = () => builder.Include<Description>();

// Assert
action.Should().Throw<Exception>();
}

[Fact(DisplayName = "Два одинаковых ограничения должны иметь одинаковый HashCode и быть равны")]
public void Should_Implement_Equality_Correctly()
{
// Arrange
var constraint1 = ActorConstraint.Include<Description>(fixture.Actors)
.Exclude<Defence>()
.Build();

var constraint2 = ActorConstraint.Include<Description>(fixture.Actors)
.Exclude<Defence>()
.Build();

// Assert
constraint1
.Should()
.Be(constraint2);

constraint1.GetHashCode()
.Should()
.Be(constraint2.GetHashCode());
}

[Fact(DisplayName = "Должен корректно работать с несколькими Include компонентами")]
public void Should_Work_With_Multiple_Includes()
{
// Arrange
var actor = fixture.CreateActor<Description>();
var constraint = ActorConstraint.Include<Description, Defence>(fixture.Actors).Build();

// Act & Assert
constraint.Applicable(actor.Id).Should().BeFalse("Нужны оба компонента");

actor.Add(new Defence());
constraint.Applicable(actor.Id).Should().BeTrue("Теперь оба компонента на месте");
}

[Fact(DisplayName = "Должен отписаться от всех событий пула при Dispose")]
public void Should_Unsubscribe_On_Dispose()
{
// Arrange
var actor = fixture.CreateActor();
var constraint = ActorConstraint.Include<Description>(fixture.Actors).Build();

using var monitoredSubject = constraint.Monitor();

// Act
((IDisposable)constraint).Dispose();
actor.Add(new Description());

// Assert
monitoredSubject.Should().NotRaise(nameof(constraint.Added));
}
}
172 changes: 172 additions & 0 deletions src/Hexecs.Tests/Actors/ActorDictionaryShould.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using Hexecs.Tests.Mocks;

namespace Hexecs.Tests.Actors;

public sealed class ActorDictionaryShould(ActorTestFixture fixture) : IClassFixture<ActorTestFixture>
{
[Fact(DisplayName = "Должен автоматически добавлять актора в словарь при добавлении компонента")]
public void Should_Add_Actor_When_Component_Added()
{
// Arrange
// Используем Name как ключ
using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
var actor = fixture.CreateActor<Description>();
var expectedName = actor.Get<Description>().Name;

// Assert
dict
.ContainsKey(expectedName)
.Should()
.BeTrue();

dict[expectedName]
.Id.Should()
.Be(actor.Id);
}

[Fact(DisplayName = "Должен обновлять ключ в словаре при изменении компонента")]
public void Should_Update_Key_When_Component_Updated()
{
// Arrange
var actor = fixture.CreateActor();
actor.Add(new Description { Name = "OldName" });

using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
// Обновляем компонент, что должно вызвать OnComponentUpdating
actor.Update(new Description { Name = "NewName" });

// Assert
dict
.ContainsKey("OldName")
.Should()
.BeFalse();

dict
.ContainsKey("NewName")
.Should()
.BeTrue();

dict["NewName"]
.Id
.Should()
.Be(actor.Id);
}

[Fact(DisplayName = "Должен удалять актора из словаря при удалении компонента")]
public void Should_Remove_Actor_When_Component_Removed()
{
// Arrange
var actor = fixture.CreateActor();
actor.Add(new Description { Name = "ToRemove" });

using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
actor.Remove<Description>();

// Assert
dict
.ContainsKey("ToRemove")
.Should()
.BeFalse();

dict
.Length
.Should()
.Be(0);
}

[Fact(DisplayName = "Должен генерировать события при добавлении и удалении")]
public void Should_Raise_Events_On_Changes()
{
// Arrange
using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

var addedRaised = false;
var removedRaised = false;
dict.Added += _ => addedRaised = true;
dict.Removed += _ => removedRaised = true;

// Act
var actor = fixture.CreateActor();
actor.Add(new Description { Name = "EventTest" });
actor.Remove<Description>();

// Assert
addedRaised
.Should()
.BeTrue();

removedRaised
.Should()
.BeTrue();
}

[Fact(DisplayName = "Должен возвращать ActorRef через TryGetActorRef")]
public void Should_TryGet_ActorRef()
{
// Arrange
var actor = fixture.CreateActor();
actor.Add(new Description { Name = "Target" });

using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
var found = dict.TryGetActorRef("Target", out var actorRef);
var notFound = dict.TryGetActorRef("Unknown", out _);

// Assert
found.Should().BeTrue();
actorRef.Id.Should().Be(actor.Id);
notFound.Should().BeFalse();
}

[Fact(DisplayName = "Должен выбрасывать исключение при обращении по несуществующему ключу через индексер")]
public void Should_Throw_When_Key_Not_Found_In_Indexer()
{
// Arrange
using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
var action = () => { _ = dict["Missing"]; };

// Assert
// Исходя из кода ActorError.KeyNotFound()
action.Should().Throw<Exception>();
}

[Fact(DisplayName = "Должен очищаться при очистке контекста")]
public void Should_Clear_When_Context_Cleared()
{
// Arrange
var actor = fixture.CreateActor();
actor.Add(new Description { Name = "ClearMe" });

using var dict = new ActorDictionary<string, Description>(
fixture.Actors,
c => c.Name);

// Act
fixture.Actors.Clear();

// Assert
dict.Length.Should().Be(0);
dict.ContainsKey("ClearMe").Should().BeFalse();
}
}
Loading