Neat way to show current and max action points!

Hi! I made an alternative (and hopefully more fun) way to show each unit’s available and total action points!

In the guide, Hugo uses a number near the health bar to show the remaining/available action points for each unit. I find it quite bland and uninteractive (you can’t directly see the max amount of action points) so I took inspiration from another game in the genre (Mutant Year Zero: Road To Eden)

Screenshot Reddit Q

Here, the player can see each character’s action points the un upper left corner of the screen.
What I did, was to add a similar bar under each unit’s health bar and make sure the code is dynamic, so it changes based on the unit’s current max action points.

Here’s the code:

public class ActionPointsVisual : MonoBehaviour
{
    [SerializeField] private Unit unit;
    [SerializeField] private Transform ActionPointsVisualSinglePrefab;
    [SerializeField] private GridLayoutGroup gridLayoutGroup;

    [SerializeField] private Transform ObjTransform;
    private List<ActionPointsVisualSingle> ChildrenList;
    private ActionPointsVisualSingle[] ChildrenArray;
    private int numberOfChildren = 0;

    private bool hasSelectedVisuals = false;

    private const float LENGHT = 1.04f;
    private const float HEIGHT = 0.1f;
    private const float SPACE_BETWEEN = 0.03f;

    private void Start()
    {
        unit.OnSingleUnitActionPointsChanged += Unit_OnActionPointsChanged;
        UnitActionSystem.Instance.OnSelectedActionChanged += unitActionSystem_OnSelectedActionChanged;

        ChildrenList = new List<ActionPointsVisualSingle>();
        BuildVisual();
        UpdateVisual();
    }

    private void OnDestroy()
    {
        unit.OnSingleUnitActionPointsChanged -= Unit_OnActionPointsChanged;
        UnitActionSystem.Instance.OnSelectedActionChanged -= unitActionSystem_OnSelectedActionChanged;
    }

    private void Unit_OnActionPointsChanged(object sender, System.EventArgs e)
    {
        if (numberOfChildren != unit.GetMaxActionPoints())
            BuildVisual();

        UpdateVisual();
    }

    private void unitActionSystem_OnSelectedActionChanged(object sender, System.EventArgs e)
    {
        if (unit == UnitActionSystem.Instance.GetSelectedUnit())
            UpdateVisual();
        else if (hasSelectedVisuals)
            UpdateVisual();
    }

    private void BuildVisual()
    {
        foreach (Transform child in ObjTransform)
            Destroy(child.gameObject);

        ChildrenList.Clear();

        numberOfChildren = unit.GetMaxActionPoints();
        for (int i = 1; i <= numberOfChildren; i++)
        {
            Transform childTransform =
                Instantiate(ActionPointsVisualSinglePrefab, ObjTransform);
            ChildrenList.Add(childTransform.gameObject.GetComponent<ActionPointsVisualSingle>());
        }

        ChildrenArray = ChildrenList.ToArray();

        gridLayoutGroup.cellSize = new Vector2((LENGHT - (numberOfChildren - 1) * SPACE_BETWEEN) / numberOfChildren, HEIGHT);
    }

    private void UpdateVisual()
    {
        bool isSelectedUnit = unit == UnitActionSystem.Instance.GetSelectedUnit();
        hasSelectedVisuals = isSelectedUnit;

        int availableActionPoints = unit.GetAvailableActionPoints();
        int cost = unit.GetSelectedActionCost();

        for (int i = 0; i < numberOfChildren; i++)
        {
            if (i < availableActionPoints)
            {
                if(isSelectedUnit && availableActionPoints >= cost && i >= availableActionPoints - cost)
                    ChildrenArray[i].SetSelected();
                else
                    ChildrenArray[i].SetAvailable();
            }
            else
            {
                ChildrenArray[i].SetUsed();
            }
        }
    }
}
public class ActionPointsVisualSingle : MonoBehaviour
{
    [SerializeField] Image availableImage;

    private bool isSelected = false;
    private float sinAngle;

    private void Update()
    {
        if ( !isSelected)
            return;

        if (sinAngle > 360f)
            sinAngle -= 360f;
        sinAngle += 4f * Time.deltaTime;

        Color tempColor = Color.white;
        tempColor.a = 0.6f + Mathf.Sin(sinAngle) * 0.4f;

        availableImage.color = tempColor;
    }

    public void SetAvailable()
    {
        availableImage.enabled = true;
        availableImage.color = Color.white;
        isSelected = false;
    }

    public void SetUsed()
    {
        availableImage.enabled = false;
    }

    public void SetSelected()
    {
        availableImage.enabled = true;
        isSelected = true;
        sinAngle = 0; // sin(0) = 1
    }
}

Some explanations:

  • ActionPointsVisual is a child of UnitWorldUI in each Unit’s world canvas
  • I had to add the OnSingleUnitActionPointsChanged event
  • the ActionPointsVisualSingle contains a background image with a small outline, and an fill image (the white rectangle)
  • You have start the OnSingleUnitActionPointsChanged event after every OnAnyActionPointsChanged
    in the unit script

I also added a SelectedVisual effect to the ActionPointsVisualSingle (the fill image changes alpha) so you can see on the unit how many points the action costs. I used a sin function (since it’s periodical) but I’m not sure if there is a better way to do it? maybe with a shader… If someone knows a better way to do this, feel free to comment :))

1 Like

Nice! In last year’s GameDev.tv game jam we did the same thing


The dots under the health bars showed the action points. We also showed the cost of each action under the buttons with bars, had magazines with ammo, grenades, etc. It was fun

2 Likes

Nice. I modified this to use this shader.

Then modified the ActionPointsVisualSingle (I call it ActionPointsSingleUI) as follows

public class ActionPointsSingleUI : MonoBehaviour
{
    [SerializeField] private Image _apImage;
    [SerializeField] private Shader _shader;
    private Material _material;
    
    private static readonly int Activate = Shader.PropertyToID("_Activate");

    private void Awake() {
        _material = new Material(_shader);
        _apImage.material = _material;
    }
    
    public void SetAvailable() {
        _apImage.enabled = true;
        _apImage.material.SetInt(Activate, 0);
    }

    public void SetUsed() {
        _apImage.enabled = false;
    }

    public void SetSelected() {
        _apImage.enabled = true;
        _apImage.material.SetInt(Activate, 1);
    }
}

The trick you need to watch out for is that if you change a shader’s properties it will change for all images that use that shader. So you need to create anew material from the shader on Awake for every “white AP box.”

1 Like

Neat one! I’ve been trying to use shaders too when I came up with the idea, but couldn’t really get the hang of them (I’m pretty new to Unity overall). I think I’ll use this instead of my logic which runs on Update() since shaders are more “elegant”. They could be quite optimized and probably run faster than my implementation that uses math functions and a clock. Thanks for the comment !!!

Privacy & Terms