Creation of a shooter with LeoECS. Part 3

Friends, in this part of a series of articles we will fix some bugs that have arisen after the changes in the previous part, start preparing the UI and start new mechanics.

Don’t forget to read last part before reading this.

First of all, let’s fix all the reloading bugs. If you start reloading and press the reload button again, the character will start doing it all over again. This behavior needs to be corrected.

Let’s create a Reloading flag component that will hang on the entity of the reloading unit, and include it in the Exclude constraint in the reloading system filter, and also introduce some changes to the logic of this system.

public struct Reloading : IEcsIgnoreInFilter
{
}

The TryReload component must be guaranteed to be removed from the entity, since it only indicates that the unit has attempted to reload. But whether the reloading will start depends on the presence of the Reloading component: if it is, then the reloading should not start; if it is not there, it should.

public class ReloadingSystem : IEcsRunSystem
{
    private EcsFilter<TryReload> tryReloadFilter;
    private EcsFilter<TryReload, AnimatorRef>.Exclude<Reloading> notReloadingFilter;
    private EcsFilter<Weapon, ReloadingFinished> reloadingFinishedFilter;
    
    public void Run()
    {
        foreach (var j in tryReloadFilter)
        {
            foreach (var i in notReloadingFilter)
            {
                ref var animatorRef = ref notReloadingFilter.Get2(i);

                animatorRef.animator.SetTrigger("Reload");

                ref var entity = ref notReloadingFilter.GetEntity(i);
                entity.Get<Reloading>();
            }
            tryReloadFilter.GetEntity(j).Del<TryReload>();
        }

        foreach (var i in reloadingFinishedFilter)
        {
            ref var weapon = ref reloadingFinishedFilter.Get1(i);
            
            var needAmmo = weapon.maxInMagazine - weapon.currentInMagazine;
            weapon.currentInMagazine = (weapon.totalAmmo >= needAmmo)
                ? weapon.maxInMagazine
                : weapon.currentInMagazine + weapon.totalAmmo;
            weapon.totalAmmo -= needAmmo;
            weapon.totalAmmo = weapon.totalAmmo < 0
                ? 0
                : weapon.totalAmmo;

            ref var entity = ref reloadingFinishedFilter.GetEntity(i);
            weapon.owner.Del<Reloading>();
            entity.Del<ReloadingFinished>();
        }
    }
}

Nested loops … don’t look pretty, do they? Especially considering that the outer loop is only needed to remove the component from the entity.

We can use the standard LeoECS feature called EcsSystems.OneFrame… It allows at some point in the system cycle to remove a specific component from all entities that have it. (accordingly, if the component was the only one, the entity is deleted along with it)

Let’s remove all TryReload components in front of the user input system, because this is where it is hung on the entity. The reload system will now look like this:

public class ReloadingSystem : IEcsRunSystem
{
    private EcsFilter<TryReload, AnimatorRef>.Exclude<Reloading> tryReloadFilter;
    private EcsFilter<Weapon, ReloadingFinished> reloadingFinishedFilter;
    
    public void Run()
    {
        // фильтруем тех, кто пытается перезарядиться и не перезаряжается на данный момент
        foreach (var i in tryReloadFilter)
        {
            ref var animatorRef = ref tryReloadFilter.Get2(i);

            animatorRef.animator.SetTrigger("Reload");

            ref var entity = ref tryReloadFilter.GetEntity(i);
            entity.Get<Reloading>();
        }

        foreach (var i in reloadingFinishedFilter)
        {
            ref var weapon = ref reloadingFinishedFilter.Get1(i);
            ...
            ...
        }
    }
}

And a startup like this:

...
private void Start()
{
    ecsWorld = new EcsWorld();
    updateSystems = new EcsSystems(ecsWorld);
    fixedUpdateSystems = new EcsSystems(ecsWorld);
    RuntimeData runtimeData = new RuntimeData();
#if UNITY_EDITOR
    Leopotam.Ecs.UnityIntegration.EcsWorldObserver.Create (ecsWorld);
    Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create (updateSystems);
#endif
    updateSystems
        .Add(new PlayerInitSystem())
        .OneFrame<TryReload>()
        .Add(new PlayerInputSystem())
        .Add(new PlayerRotationSystem())
        .Add(new PlayerAnimationSystem())
        .Add(new WeaponShootSystem())
        .Add(new SpawnProjectileSystem())
        .Add(new ProjectileMoveSystem())
        .Add(new ProjectileHitSystem())
        .Add(new ReloadingSystem())
        .Inject(configuration)
        .Inject(sceneData)
        .Inject(runtimeData);
...

It is not necessary to address this issue through a flag component. In fact, any flag component can be replaced with the most common bool stored in some component, so if desired, our Reloading component can be easily turned into a boolean variable.

Now we need to fix another bug, also related to animations. If you start reloading on the go, you will notice that the entire body of the unit has gone into reload animation. Including legs that stay in place while the character is moving. This problem is solved by creating two layers in the Animator – one for the upper body parts, the other for the lower ones.

We will leave the main animator layer as it is, and create a new one for the lower body parts and name it Lowerbody. Assign an appropriate Avatar Mask, which affects only the legs, and assign it in the second layer of the animator.

Since I am using an asset in which the animations were not prepared for blending, the result came out strange for me, but if the content is done correctly, everything will work as it should. This also applies to the firing logic that I implemented through the Animation Event. If you prepare content differently and separate animations, you don’t need to implement shooting that way.

Now we can move on to creating the UI in our project.

First of all, you need to understand that not all parts of the project need to be written with ECS. Yes, it allows us to conveniently write and refactor game logic, but ECS is about linear processing. Hierarchical structures, one way or another connected with graphs, do not fit well with it. These include FSM, GOAP, Behavior / Decision tree, UI, and more. Therefore, it is better to implement these structures as services and implement them in ECS in the future.

We will need to both catch and handle UI events (for example, when a button is pressed, etc.), and be able to somehow change it (open / close the pop-up, change the label, etc.). To handle events, we can use an extension of the framework for working with the UI, created by the author himself, and to change parts of the interface, we must create separate classes for various elements (pop-ups, etc.) and implement them into LeoECS systems.

In this case, we will not need to implement them all separately. We can create one MonoBehaviour UI class, which will have links to the main screens in the game, for which separate classes will also be created. Within these screens, there will also be links to labels, progress bars, other screens, or other UI elements. Let’s get down to the code.

The first step is to create a MonoBehaviour UI component that will hang on the canvas.

public class UI : MonoBehaviour
{
}

And also the abstract class Screen.

public abstract class Screen : MonoBehaviour
{
    public virtual void Show(bool state = true)
    {
        gameObject.SetActive(state);
    }
}

Let’s take a look at the design of the user interface itself. For now, the pause menu and the game screen with the ammo counter will be enough for us.

  • Canvas – the UI itself

  • EventSystem – an object that handles events

  • GameScreen – empty object, game screen

  • CurrentMagazineInLabel – the label for the current number of cartridges in the clip

  • SlashLabel – a label for separating two adjacent

  • TotalAmmoLabel – label for all cartridges

  • PauseScreen – empty object, pause menu

  • BackgroundPanel – a semi-transparent dark sprite

  • PauseLabel – a label with the words “PAUSED”

As you might have guessed, from the code we will need to change at least the labels responsible for the number of cartridges. Let’s create separate MonoBehaviour classes for UI elements and add links to them in the fields of the UI class.

using TMPro;

public class GameScreen : Screen
{
    public TextMeshProUGUI currentInMagazineLabel;
    public TextMeshProUGUI totalAmmoLabel;
}
public class PauseScreen : Screen
{
}
public class UI : MonoBehaviour
{
    public GameScreen gameScreen;
    public PauseScreen pauseScreen;
}

Don’t forget to also create a field of type UI in the EcsStartup class …

public class EcsStartup : MonoBehaviour
{
    public StaticData configuration;
    public SceneData sceneData;
    public UI ui;

    private EcsWorld ecsWorld;
    private EcsSystems updateSystems;
    private EcsSystems fixedUpdateSystems;
    ...

… and also manually populate the field in the inspector with a Canvas object and inject the instance into the updateSystems loop:

updateSystems
            .Add(new PlayerInitSystem())
            .OneFrame<TryReload>()
            .Add(new PlayerInputSystem())
            ...
            .Add(new ReloadingSystem())
            .Inject(configuration)
            .Inject(sceneData)
            .Inject(ui)
            .Inject(runtimeData);

Let’s make it so that when the player shoots, the UI elements for the ammo are updated. You also need to initialize them at the start.

Let’s add a couple of new lines to the PlayerInitSystem:

ui.gameScreen.currentInMagazineLabel.text = weapon.currentInMagazine.ToString();
ui.gameScreen.totalAmmoLabel.text = weapon.totalAmmo.ToString();

And in WeaponShootSystem:

public class WeaponShootSystem : IEcsRunSystem
{
    private EcsFilter<Weapon, Shoot> filter;
    private UI ui;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var weapon = ref filter.Get1(i);

            ref var entity = ref filter.GetEntity(i);
            entity.Del<Shoot>();
            
            if (weapon.currentInMagazine > 0)
            {
                weapon.currentInMagazine--;
                // проверяем, игрок ли стреляет
                if (weapon.owner.Has<Player>())
                {
                    ui.gameScreen.currentInMagazineLabel.text = weapon.currentInMagazine.ToString();
                    ui.gameScreen.totalAmmoLabel.text = weapon.totalAmmo.ToString();
                }
                ...

You may have noticed that these two lines of code are repeated in two places. In the future, they may be needed somewhere else, so it makes sense to move this piece of code into a separate block, for example, into a method of the GameScreen class. Then, instead of these repeating two long lines, we get:

ui.gameScreen.SetAmmo(weapon.currentInMagazine, weapon.totalAmmo);
public class GameScreen : Screen
{
    // Для инкапсуляции мы можем даже сделать поля приватными и пометить атрибутом SerializeField, чтобы они были видны в инспекторе
    [SerializeField] private TextMeshProUGUI currentInMagazineLabel;
    [SerializeField] private TextMeshProUGUI totalAmmoLabel;

    public void SetAmmo(int current, int total)
    {
        currentInMagazineLabel.text = current.ToString();
        totalAmmoLabel.text = total.ToString();
    }
}

It is also necessary to call this method in the ReloadingSystem at the end of the reload:

...
ref var entity = ref reloadingFinishedFilter.GetEntity(i);
if (weapon.owner.Has<Player>())
{
    ui.gameScreen.SetAmmo(weapon.currentInMagazine, weapon.totalAmmo);
}
weapon.owner.Del<Reloading>();
entity.Del<ReloadingFinished>();
...

Now we need to find a use for the pause menu we created. When you press the Escape key, you need to pause the game and show it, and when you press the Escape key, you need to remove it and continue the game. Let’s create a boolean variable isPaused and place it in our runtime data state.

public class RuntimeData
{
    public bool isPaused = false;
}

Let’s modify the user input system a bit.

...
if (Input.GetKeyDown(KeyCode.Escape))
{
    ecsWorld.NewEntity().Get<PauseEvent>();
}
public struct PauseEvent : IEcsIgnoreInFilter
{
}

And let’s create a new pause system.

public class PauseSystem : IEcsRunSystem
{
    private EcsFilter<PauseEvent> filter;
    private RuntimeData runtimeData;
    private UI ui;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            filter.GetEntity(i).Del<PauseEvent>();
            runtimeData.isPaused = !runtimeData.isPaused;
            Time.timeScale = runtimeData.isPaused ? 0f : 1f;
            ui.pauseScreen.Show(runtimeData.isPaused);
        }
    }
}

There is only one problem. Even if the game is paused, our character will turn towards the mouse. Let’s solve this problem like this:

public class PlayerRotationSystem : IEcsRunSystem
{
    private EcsFilter<Player> filter;
    private SceneData sceneData;
    private RuntimeData runtimeData;

    public void Run()
    {
        if (runtimeData.isPaused) return;
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);
            ...

Perfectly! The game can now be paused.

With each part, our project on LeoECS becomes more and more elaborate. In the next article, we will continue to implement various mechanics and start making enemies.

Link to the repository with the project

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *