GnP 4: Sync participant scores and shoot VFX

In this last part of the lesson, we'll practice with the ECS some more by using it to sync participant scores and shoot VFX.

Sync participant scores and shoot VFX

In this final chapter we'll implement another System, a way to visualize scores during gameplay as well as sync a leaderboard at the end of a gameplay round. It also handles other participants' shooting FX. So let's create a new C# script inside the Systems directory and call it ^ParticipantsSystem.cs^. This script will behave similarly to the ^HostilesSystem.cs^ script, so the app will be able to manage new components called ^SCORE.COMPONENT^ and ^SHOOT.FX.COMPONENT^, and it can listen to the changes as well.

using System;
using System.Collections.Generic;
using Auki.ConjureKit;
using Auki.ConjureKit.ECS;
using ConjureKitShooter.Models;
using UnityEngine;

public class ParticipantsSystem: SystemBase
{
    private byte[] _emptyData = new byte[1];

    private uint _scoreComponentTypeId;
    private uint _shootFxComponentTypeId;

    public event Action<uint, ScoreData> OnParticipantScores;
    public event Action<uint, ShootData> InvokeShootFx;

    private const string ScoreComponent = "SCORE.COMPONENT";
    private const string ShootFxComponent = "SHOOT.FX.COMPONENT";

    public ParticipantsSystem(Session session) : base(session)
    {

    }

    /// <summary>
    /// Method to generate the components type Id with type of uint
    /// </summary>
    public void GetComponentsTypeId()
    {
        _session.GetComponentTypeId(ScoreComponent, id => _scoreComponentTypeId = id,
            error => Debug.LogError(error.TagString()));
        _session.GetComponentTypeId(ShootFxComponent, id => _shootFxComponentTypeId = id,
            error => Debug.LogError(error.TagString()));
    }

    public override string[] GetComponentTypeNames()
    {
        return new[] {ScoreComponent, ShootFxComponent};
    }

    public override void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)
    {
        foreach (var c in updated)
        {
            if (c.component.ComponentTypeId == _scoreComponentTypeId)
            {
                var data = c.component.Data.FromJsonByteArray<ScoreData>();
                OnParticipantScores?.Invoke(c.component.EntityId, data);
            }

            if (c.component.ComponentTypeId == _shootFxComponentTypeId)
            {
                if (c.localChange) return;

                var data = c.component.Data.FromJsonByteArray<ShootData>();
                if (data == null) return;
                InvokeShootFx?.Invoke(c.component.EntityId, data);
            }
        }
    }

    public override void Delete(IReadOnlyList<(EntityComponent component, bool localChange)> deleted)
    {

    }

    public void GetAllScoresComponent(Action<List<EntityComponent>> onComplete)
    {
        _session.GetComponents(_scoreComponentTypeId, result =>
        {
            onComplete?.Invoke(result);
        }, error =>
        {
            Debug.LogError(error);
            onComplete?.Invoke(null);
        });
    }

    public void AddParticipantComponent(uint entityId, string name)
    {
        var data = new ScoreData()
        {
            name = name
        }.ToJsonByteArray();
        var ec = _session.GetEntityComponent(entityId, _scoreComponentTypeId);
        if (ec != null)
        {
            _session.UpdateComponent(_scoreComponentTypeId, entityId, data);
            return;
        }
        _session.AddComponent(_scoreComponentTypeId, entityId, data, null, Debug.LogError);
        _session.AddComponent(_shootFxComponentTypeId, entityId, _emptyData, null, Debug.LogError);
    }

    public void UpdateParticipantScoreComponent(uint entityId, int score)
    {
        var prevData = _session.GetEntityComponent(entityId, _scoreComponentTypeId);
        var data = prevData.Data.FromJsonByteArray<ScoreData>();
        data.score = score;
        _session.UpdateComponent(_scoreComponentTypeId, entityId, data.ToJsonByteArray());
    }

    public void SyncShootFx(uint entityId, Vector3 pos, Vector3 hit)
    {
        var data = new ShootData()
        {
            StartPos = new SVector3(pos),
            EndPos = new SVector3(hit)
        }.ToJsonByteArray();
        _session.UpdateComponent(_shootFxComponentTypeId, entityId, data);
    }
}

Let's add these lines to ^ParticipantsController.cs^:

using Auki.ConjureKit;
using ConjureKitShooter.UI;

As well as these new fields:

[SerializeField] private ParticipantNameUi participantNameUiPrefab;
[SerializeField] private LineRenderer shootFxPrefab;

private IConjureKit _conjureKit;

private readonly Dictionary<uint, ParticipantComponent> _participantComponents = new();
private Dictionary<uint, (string, int)> _scoreCache = new();
private Main _main;
private WaitForSeconds _delay;
private Session _session;
private Transform _camera;

Now we should update the ^SetListener()^ method, and add RemoveListener() as well. Instead of passing ^Main^ to the ^SetListener()^ method like it was previously, now we'll pass ^ParticipantsSystem^ to ^SetListener()^ so that all participants can listen for score changes using the System and Components:

public void SetListener(ParticipantsSystem participantsSystem)
{
    participantsSystem.OnParticipantScores += OnParticipantScores;
    participantsSystem.InvokeShootFx += ShowParticipantShootLine;
}

public void RemoveListener(ParticipantsSystem participantsSystem)
{
    participantsSystem.OnParticipantScores -= OnParticipantScores;
    participantsSystem.InvokeShootFx -= ShowParticipantShootLine;
}

Let's also declare the ^ShowParticipantShootLine()^ method so the code above doesn't throw an error:

private void ShowParticipantShootLine(uint id, ShootData data)
{

}

As well as the ^Initialize()^ method which will initialize all the needed fields in this controller. It also handles all the prefabs' lifetimes whenever ConjureKit connects to a session and disconnects.

public void Initialize(IConjureKit conjureKit, Transform camera)
{
    _conjureKit = conjureKit;
    _delay = new WaitForSeconds(0.2f);
    _camera = camera;

    _conjureKit.OnJoined += session => _session = session;
    _conjureKit.OnLeft += session =>
    {
        _scoreCache.Clear();
        foreach (var c in _participantComponents)
        {
            if (c.Value == null) continue;
            Destroy(c.Value.NameUi.gameObject);
            Destroy(c.Value.LineRenderer.gameObject);
        }
        _participantComponents.Clear();
    };
}

Next we want to implement a way to get all previous components from ^ParticipantSystem.cs^:

public void GetAllPreviousComponents(ParticipantsSystem participantsSystem)
{
    participantsSystem.GetAllScoresComponent(result =>
    {
        foreach (var ec in result)
        {
            var data = ec.Data.FromJsonByteArray<ScoreData>();
            OnParticipantScores(ec.EntityId, data);
        }
    });
}

We also need to update ^OnParticipantJoins()^ so it will spawn all needed game objects whenever a participant joins:

private void OnParticipantJoins(uint id, string participantName)
{
    if (_participantComponents.TryGetValue(id, out var c))
    {
        c.NameUi.SetName(participantName);
        return;
    }

    var lRenderer = Instantiate(shootFxPrefab, transform);
    var nameSign = Instantiate(participantNameUiPrefab, transform);

    nameSign.SetName(participantName);

    lRenderer.enabled = false;
    nameSign.gameObject.SetActive(false);

    _participantComponents.Add(id, new ParticipantComponent(lRenderer, nameSign));
    _scoreCache.TryAdd(id, (participantName, 0));
}

Next let's modify the ^OnParticipantScores()^ method which updates the players' score displays:

private void OnParticipantScores(uint id, ScoreData data)
{
    if (!_participantComponents.ContainsKey(id))
    {
        OnParticipantJoins(id, data.name);
        return;
    }

    _participantComponents[id].Score = data.score;
    _participantComponents[id].NameUi.SetName(data.name);
    _participantComponents[id].NameUi.SetScore(data.score.ToString("0000000"));
    _scoreCache[id] = (data.name, data.score);
}

We also need to declare a method to handle participants leaving the session, so the controller can remove the Components associated with that player. Let's name the method ^OnParticipantLeft(uint id)^:

public void OnParticipantLeft(uint id)
{
    if (!_participantComponents.ContainsKey(id))
        return;

    Destroy(_participantComponents[id].LineRenderer.gameObject);
    Destroy(_participantComponents[id].NameUi.gameObject);

    _participantComponents.Remove(id);
}

Next, create a couple of methods that will handle the Participant's Score Board:

private void UpdateScoreBoardPosition(uint id, Vector3 pos, Vector3 cameraPos)
{
    if (!_participantComponents.ContainsKey(id))
        return;

    var nameSign = _participantComponents[id].NameUi.transform;

    nameSign.gameObject.SetActive(true);

    var offsetPos = pos + (0.6f * Vector3.up);
    var direction = -(cameraPos - offsetPos);

    var distance = direction.magnitude;

    nameSign.position = offsetPos;
    nameSign.rotation = Quaternion.LookRotation(direction);

    nameSign.transform.localScale = Mathf.Clamp(distance, 0.2f, 1.2f) * Vector3.one;
}

private void UpdateParticipantsScoreBoard()
{
    if (_participantComponents.Count <= 0) return;

    foreach (var c in _participantComponents)
    {
        if (c.Value == null) continue;

        var entity = _session.GetEntity(c.Key);

        if (entity == null || entity.ParticipantId == _session.ParticipantId)
            continue;

        var pos = _session.GetEntityPose(c.Key).position;
        UpdateScoreBoardPosition(c.Key, pos, _camera.position);
    }
}

private void Update()
{
    UpdateParticipantsScoreBoard();
}

The ^UpdateScoreBoardPosition()^ method will try to find the score board GameObject from the ^_participantComponents^ collection, and will reposition that GameObject to the position value that is passed to the method as an argument (with an offset of 60 cm on the Y axis, so it will be above the player), and then align the rotation so it faces the camera.

The ^UpdateParticipantsScoreBoard()^ method will loop through all the ^_participantComponents^ collection values and try to get the Entity based on the key value of the collection (which is an ^uint^), and then pass the Entity Id, Entity Position, and Camera position on each loop to update each participant's score board positions.

Since ^ParticipantsController.cs^ is a Monobehaviour, we can call the ^UpdateParticipantsScoreBoard()^ method inside the built-in ^Update()^ method. This way it will be evaluated in every frame and the score boards will follow their assigned participants.

Next let's add a way to show each participant's Shoot Line Fx. We'll begin by adding this namespace since it's needed by Coroutines:

using System.Collections;

Then let's update the ^ShowParticipantShootLine()^ method and create the Coroutine method which we'll call ^ShowShootLine()^:

private void ShowParticipantShootLine(uint id, ShootData data)
{
    if (!_participantComponents.ContainsKey(id))
        return;

    StartCoroutine(ShowShootLine(id, data.StartPos.ToVector3(), data.EndPos.ToVector3()));
}

IEnumerator ShowShootLine(uint id, Vector3 pos, Vector3 hit)
{
    var lineRenderer = _participantComponents[id].LineRenderer;
    lineRenderer.enabled = true;
    lineRenderer.positionCount = 2;
    lineRenderer.SetPositions(new[]{pos, hit});

    yield return _delay;

    lineRenderer.positionCount = 0;
    lineRenderer.enabled = false;
}

^ShowParticipantShootLine()^ is already subscribed to the ^participantSystem.InvokeShootFx^ callback, so whenever the callback gets invoked, ^ShowParticipantShootLine()^ will be notified, and will receive ShootData for the Shoot Fx (which contains the positions for the LineRenderer). With that data, we can invoke the ^ShowShootLine()^ coroutine which will show the line for 200ms.

The last thing that we need to implement in ^ParticipantsController.cs^ is a way to reset the score, so let's create a new method for it:

public void Restart()
{
    foreach (var c in _participantComponents)
    {
        c.Value.NameUi.SetScore(0.ToString("0000000"));
        _scoreCache[c.Key] = (c.Value.NameUi.GetName(), 0);
    }
}

We also want to remove the clearing of ^_scoreCache^ whenever we are getting the score entries:

public void GetScoreEntries(Action<Dictionary<uint, (string, int)>> valueCallback)
{
    ...
    ~~
    _scoreCache.Clear(); //remove this line
    ~~
}

Next we need to update ^Main.cs^ so that ^ParticipantsController.cs^ gets initialized correctly. There's an error now with ^SetListener()^ which we'll fix by replacing the ^SetListener()^ call in ^Start()^:

private void Start()
{
    ...

    uiManager.OnChangeState += OnChangeGameState;
    ~~
    participantsController.SetListener(this);
    ~~

    ...
}

With ^Initialize():^

private void Start()
{
    ...

    uiManager.OnChangeState += OnChangeGameState;
    ~~
    participantsController.Initialize(_conjureKit, arCamera.transform);
    ~~

    ...
}

We will also need to initialize the Participants System inside the ^OnJoined()^ callback, as well as call ^participantsController.SetListener()^. First let's add a field that will hold the reference to the system:

private ParticipantsSystem _participantsSystem;

Now update the ^OnJoined()^ method:

private void OnJoined(Session session)
{
    ...
    _session.RegisterSystem(_hostilesSystem, () =>
    {
        _hostilesSystem.GetComponentsTypeId();
    });

    ~~
    _participantsSystem = new ParticipantsSystem(_session);
    _session.RegisterSystem(_participantsSystem, () =>
    {
        _participantsSystem.GetComponentsTypeId();
        participantsController.GetAllPreviousComponents(_participantsSystem);
    });
    ~~

    _gameEventController.OnGameStart = GameStart;
    _gameEventController.OnGameOver = GameOver;
    ~~
    _participantsSystem.OnParticipantScores += UpdateParticipantsEntity;
    ~~

    hostileController.SetListener(_hostilesSystem);
    ~~
    participantsController.SetListener(_participantsSystem);
    ~~
    uiManager.SetSessionId(_session.Id);
}

Next, create an overload method for ^UpdateParticipantsEntity()^ that takes ^uint^ and ^ScoreData^ as arguments, so we can subscribe the method to the ParticipantsSystem's ^OnParticipantScores^ event callback. This allows us to update the remaining Participants' entities if there are changes in the Session (e.g. if players leave, etc):

private void UpdateParticipantsEntity(uint id, ScoreData data)
{
    UpdateParticipantsEntity();
}

We'll also update the ^OnLeft()^ callback:

private void OnLeft(Session lastSession)
{
    hostileController.RemoveListener();
    ~~
    participantsController.RemoveListener(_participantsSystem);
    _participantsSystem.OnParticipantScores -= UpdateParticipantsEntity;
    _participantEntities.Clear();
    _spawnedGun.Clear();
    ~~
    GameOver();
    _hostilesSystem = null;
    ~~
    _session = null;
    ~~
}

Now ^_spawnedGun.Clear()^ will show an error, but we'll fix that in a moment. For now, let's update the ^OnEntityDeleted()^ callback:

private void OnEntityDeleted(uint entityId)
{
    ~~
    participantsController.OnParticipantLeft(entityId);
    ~~
    UpdateParticipantsEntity();

    if (_participantEntities.Count < 2)
    {
        GameOver();
    }
}

We also want to reset the score whenever the game restarts by calling ^participantsController.Restart()^, so let's update the ^GameStart()^ method accordingly:

private void GameStart()
{
    ....
    uiManager.ChangeUiState...
    ~~
    participantsController.Restart();
    ~~
    UpdateParticipantsEntity....
}

Next let's update and fix ^GunScript.cs^. First remove the ^Initialize()^ call in the ^Start()^ method:

private void Start()
{
    lineRenderer.positionCount = 0;
    lineRenderer.enabled = false;

    _audio = GetComponent<AudioSource>();
        
    ~~
    Initialize(); // Remove
    ~~
}

Then declare a new field to reference ParticipantsSystem:

private ParticipantsSystem _participantsSystem;

Update the ^Initialize()^ method:

public void Initialize(ParticipantsSystem participantsSystem, uint entityId)
{
    _participantsSystem = participantsSystem;
    _myEntityId = entityId;
    _delay = new WaitForSeconds(0.2f);
}

Let's also add a new ^Clear()^ method to clear the ^_participantsSystem^ value. This step should also fix the error we had previously:

public void Clear()
{
    _participantsSystem = null;
}

We'll also update the ^ShowShootLine()^ coroutine so it sends a "Shooting Sync Event":

IEnumerator ShowShootLine(uint entityId, Vector3 pos, Vector3 hit)
{
    ~~
    _participantsSystem?.SyncShootFx(entityId, pos, hit);
    ~~
    _audio.PlayRandomPitch(shootSfx, 0.18f);
    ....
}

Let's go back to ^Main.cs^. We need to update the ^OnParticipantEntityCreated()^ callback, but first let's add a field that will hold the current user's device entity:

private Entity _myEntity;

Then update the ^OnParticipantEntityCreated()^ method so it caches the user's device entity and initializes the gun. Broadcasting the addition of the Participant Components happens via ^_participantSystem^:

private void OnParticipantEntityCreated(Entity entity)
{
    _myEntity = entity;
    _spawnedGun.Initialize(_participantsSystem, entity.Id);
    _participantsSystem.AddParticipantComponent(entity.Id, _myName);
}

We also want to update the ^ShootLogic()^ method in ^Main.cs^ so it invokes the Participant System's ^UpdateParticipantScoreComponent()^ method. Remove this line:

private void ShootLogic()
{
    ...

                if (hostile.Hit(hitInfo.point))
                {
                    _score += 10;
                    ~~
                    OnParticipantScore?.Invoke(0, new ScoreData(){name = _myName, score = _score});
                    ~~
                    uiManager.UpdateScore(_score);
                }
    ...
}

And replace it with this:

private void ShootLogic()
{
    ...

                if (hostile.Hit(hitInfo.point))
                {
                    _score += 10;
                    ~~
                    _participantsSystem.UpdateParticipantScoreComponent(_myEntity.Id, _score);
                    ~~
                    uiManager.UpdateScore(_score);
                }
    ...
}

start-note

Lastly, we'll need a reconnect mechanism whenever the participant is disconnected from the session. We can do this inside the ^OnStateChange()^ callback by running the ^Connect()^ method whenever the state is Disconnected:

private void OnStateChange(State state)
{
   ....

   if (state == State.Disconnected)
   {
       _conjureKit.Connect();
   }
}

end-note

Now let's go back to Unity Editor. Since we changed ^ParticipantsController.cs^, let's assign a couple of variables in the scene:

Now the game should be completed, and can be played by multiple participants.

The full project can be found on GitHub.

Need a refresher on the essentials?

Check out more lessons, DIY kits and essentials reading material at the developer learning centre homepage.

Want to build on the posemesh and need a helping hand?

Apply for a grant of AUKI tokens to get your project off the ground, and work directly with the Auki Labs team to get your creation to market. Successful applicants may be granted up to 100k USD worth of AUKI tokens, and development and marketing support from the Auki Labs team.