Due to license restrictions, this repository does not include the Leopotam.EcsProto and Leopotam.EcsProto.QoL packages (they are added to .gitignore).
You can read about EcsProto and learn how to get a copy on the author’s website.
In EcsProto (as in other ECS frameworks), it is not recommended to use async syntax (System.Threading.Task, UniTask, UnityEngine.Awaitable) when working with ProtoEntity, because these async types have an implicit scheduler that works outside the SystemGroup loop. This can lead to unpredictable errors when accessing ProtoEntity data.
AsyncSystem solves this problem by using its own Routine type, which:
- does not use a scheduler,
- is wrapped in a system and executed strictly within the system order,
- before every
awaitcall checks that the entity matches the filter requirements, that the entity is alive, the world is alive, etc.
Thus, working with ProtoEntity remains safe throughout the entire async method, without extra boxing/unboxing, since all checks are performed before await is triggered (see AsyncSystem).
- Reduces the number of auxiliary marker components needed only for waiting and not carrying business logic.
- Allows expressing a Behaviour Tree with
Routine<bool>and writing it inasync/awaitsyntax while keeping execution under control.
- Start with an
asyncsystem for quick logic testing. - As things stabilize, move important components out and split them into regular ECS systems.
- Interaction with UI is often complex and inconvenient to split into separate systems.
- Since UI rarely requires mass processing, using
asyncsimplifies the code. - If needed, it can be easily refactored into regular systems.
AsyncSystem is a base class that allows writing asynchronous logic in EcsProto in a linear style using async/await syntax. This simplifies the code by eliminating callback hell and allows using language features such as:
- variable scope,
- flow control,
- exception handling (
try/finally).
To add an AsyncSystem, create a class inherited from AsyncSystem<TSelf> and implement two methods:
class MySystem : AsyncSystem<MySystem> {
// filter as a start condition for async logic
protected override IProtoIt GetProtoIt () => new ProtoIt(It.Inc<SomeComponent>());
protected override async Routine Run (ProtoEntity entity){
await Routine.When(...)
// ...
}
}- Under the hood, a custom Task-like type
Routineis used, details in README_ROUTINE.md. - For deferred cleanup, the
Scopetype is used (similar todeferin Go), details in README_SCOPE.md.
- Starts as soon as the entity gets the
CUnitcomponent (see filter). - Changes color depending on health (>=50 green, >0 yellow, red).
- In parallel, updates the health bar with a slider and text (see
parallel). - When health is 0, waits 3 seconds and removes the unit component.
class SysUnit : AsyncSystem<SysUnit> {
[DI] GameAspect _counterAspect = default;
[DI] SceneContext _scene = default;
protected override IProtoIt GetProtoIt () => new ProtoIt (It.Inc<CUnit> ());
protected override async Routine Run (ProtoEntity entity) {
var scope = await Routine.GetScope ();
var view = Object.Instantiate (_scene.UnitPrefab, _scene.UnitSpawn);
scope.Add (() => Object.Destroy (view.gameObject));
var parallel = await Routine.GetParallel ();
parallel.Attach (ObserveAmount ());
// healthy state
view.Image.color = Color.green;
await Routine.When (() => _counterAspect.CUnit.Get (entity).Health < 50);
// injured state
view.Image.color = Color.yellow;
await Routine.When (() => _counterAspect.CUnit.Get (entity).Health <= 0);
// dead state
view.Image.color = Color.red;
var timer = 3f;
await Routine.When (() => {
timer -= Time.deltaTime;
return timer < 0f;
});
_counterAspect.CUnit.Del (entity);
return;
async Routine ObserveAmount () {
while (true) {
var cache = _counterAspect.CUnit.Get (entity).Health;
view.Slider.value = cache;
view.Text.text = cache.ToString ();
await Routine.When (() => cache != _counterAspect.CUnit.Get (entity).Health);
}
}
}
}- Waits for
StartBtnclick to start the game. - Creates an entity with the
CUnit (Health = 100)component. - Enables the attack button and subscribes logic to its click within
using (unitAliveScope). - Ends the game when no units remain, waits for
GameOverBtnclick. - Repeats the process.
class SysGameFlow : AsyncSystem<SysGameFlow> {
[DI] GameAspect _gameAspect = default;
[DI] SceneContext _sceneContext = default;
override IProtoIt GetProtoIt () => new ProtoIt (It.Inc<CGame> ());
override async Routine Run (ProtoEntity entity) {
_sceneContext.MenuRoot.SetActive (true);
while (true) {
using var roundScope = new Scope ();
await _sceneContext.StartBtn.WaitForClick ();
// game started
_sceneContext.MenuRoot.SetActive (roundScope, false);
_sceneContext.GameRoot.SetActive (roundScope, true);
_gameAspect.CUnit.NewEntity () = new () { Health = 100 };
using (var unitAliveScope = roundScope.NestedScope ()) {
_sceneContext.ShootBtn.onClick.AddListener (unitAliveScope, Shoot);
await Routine.When (() => _gameAspect.UnitIt.Len () == 0);
// game over
}
await _sceneContext.GameOverBtn.WaitForClick ();
}
}
void Shoot () {
foreach (var unitE in _gameAspect.UnitIt) {
_gameAspect.CUnit.Get (unitE).Health -= 10;
}
}
}