top of page

Tactics Engine: Units

  • Writer: Anthony Nguyen
    Anthony Nguyen
  • Aug 18, 2020
  • 7 min read

This is going to be a rather code-heavy post as I will be going over how I manage everything related to units during the combat portion of the game, which involves managing a multitude of data effectively.


Last time we have set up the game board, now it's time to set up the chess pieces.


Unit Manager

This will be the main class that handles everything related to units during combat.

<CodeLanguage="CSharp">
public class UnitManager : MonoBehaviour
{
    #region Properties
    BattleHandler bh;
    Map map;

    public SpawnData initialSpawnData;

    public Dictionary<Alliances, List<Team>> teams = new Dictionary<Alliances, List<Team>>();
    public Dictionary<Alliances, List<Unit>> units = new Dictionary<Alliances, List<Unit>>();
    public Dictionary<Alliances, int> unitIndex = new Dictionary<Alliances, int>();
    #endregion
    ...
}
</Code>

To begin with, I defined private member variables to the BattleHandler (the GameManager) and the Map (which holds all the Tile objects) which I will be using GetComponent<> once during initialization. SpawnData is a ScriptableObject in which I can declare what units to spawn initially per level.

For keeping track of units, I used Dictionary<TKey, TValue> (or unordered_map<Key, T> in C++) as my primary means of containers for them. The Dictionaries will use an enum Alliances (Player, Enemy, NPC, etc) as keys to get access to the specific list of units that belong to that Alliance.

Since I have implemented a "team" system, in which you can group up 2 to 3 characters together, I needed a dictionary to keep track of every team on the map. The second dictionary keeps track of the individual units by themselves is mainly used for cutscenes and dialogues. A third dictionary keeps track of the different indices of the current unit belonging to each alliance currently viewed by the player.


<CodeLanguage="CSharp">
 public void Init()
  {
        bh = GetComponentInParent<BattleHandler>();
        map = bh.map;

        for (int i = 0; i < (int)Alliances.COUNT; i++)
        {
            teams[(Alliances)i] = new List<Team>();
            units[(Alliances)i] = new List<Unit>();
        }
  }
</Code>

The Init() method for this class simply get the references to the BattleHandler, the Map, and initializes new Lists for the Dictionaries (based on the number of Alliances).










For reference, this is how my combat system is set up, the BattleHandler will contain multiple child managers each handling a certain aspect of the combat system. The BattleHandler and the managers will get a reference to one another via a single GetComponent<> call during the initialization stage of a level when the scene is loaded.



<CodeLanguage="CSharp">
public IEnumerator SpawnUnits(SpawnData data)
    {
        yield return StartCoroutine(SpawnUnits(data.playerUnits,    Alliances.Player, data.spawnType));
        yield return StartCoroutine(SpawnUnits(data.enemyUnits,     Alliances.Enemy, data.spawnType));
        yield return StartCoroutine(SpawnUnits(data.npcUnits,       Alliances.NPC, data.spawnType));
        yield return StartCoroutine(SpawnUnits(data.neutralUnits,   Alliances.Neutral, data.spawnType));
    }
</Code>

For spawning units, I will simply have a Coroutine that takes in a SpawnData object that indicates which units to spawn for which alliance.


<CodeLanguage="CSharp">
public class SpawnData : ScriptableObject
{
    public SpawnType spawnType;

    [Header("Unit Lists")]
    public List<TeamSpawnData> playerUnits;
    public List<TeamSpawnData> enemyUnits;
    public List<TeamSpawnData> npcUnits;
    public List<TeamSpawnData> neutralUnits;
}
</Code>

The SpawnData class simply has 4 lists of TeamSpawnData which contains what units to spawn for a team and the stats for each unit. Alternatively, I could simply just had a single array of TeamSpawnData with Alliance included to specify which alliance a team would belong to, but for convenience's sake it is easier to keep track of which alliance will have which team if I divided the SpawnData up into 4 different arrays.


Creating the Units

For the part that does the brunt of the work, a sub-coroutine of SpawnUnits() that takes an individual list of SpawnData, an Alliance, and a spawnType (to indicate whether or not to show animations).



<CodeLanguage="CSharp">
IEnumerator SpawnUnits(List<TeamSpawnData> list, Alliances alliance, SpawnType spawnType, bool loadSavedStats = true)
    {
        foreach (var data in list)
        {
            Point position = FindSpawnPoint(data, alliance);

            Tile target = bh.map.FindValidTile(position);
            if (target == null)
                continue;

            if (spawnType == SpawnType.InBattle)
                yield return StartCoroutine(PanCamera(target.pos));

            Team team = InstantiateTeam(data, target, alliance);
            if (team != null)
                teams[alliance].Add(team);

            if (spawnType == SpawnType.InBattle)
                SpawnEffect(team);
        }
    }
</Code>

I divided this process into a number of steps:

Iterate through the provided list, and do the following of each SpawnData

  • Find a spawn location on the map (either by checking for spawner tiles or using the specificed coordinates in the SpawnData)

  • Verify if a tile exists at the found location. If not, we skip these units.

  • If spawnType is specified as InBattle, then the camera will pan to the spawn location (this is used for cutscenes where reinforcements show up)

  • Instantiates the unit objects and create a team out of them.

  • Add visual and sound effects if specified.


For finding the spawn point, I have 2 of the following helper methods:


<CodeLanguage="CSharp">
Point FindSpawnPoint(TeamSpawnData data, Alliances alliance)
    {
        Point point = new Point((int)data.position.x, (int)data.position.y);

        bool containsBoss = false;
        for (int i = 0; i < data.units.Count; i++)
        {
            if (data.units[i].boss)
            {
                containsBoss = true;
                break;
            }
        }

        point = FindUnitSpawnPoint(data.units[0].ID, bh.map.spawners[alliance], point, containsBoss);

        return point;
    }
</Code>
<CodeLanguage="CSharp">
Point FindUnitSpawnPoint(string ID, List<Tile> spawners, Point prev, bool boss = false)
    {
        Tile tile = null;

        foreach (var t in spawners)
        {
            Spawner s = t.spawner;
            if (s.ID == ID)
            {
                return t.pos;
            }

            if (!t.Occupied())
            {
                if (boss && s.boss == boss)
                    return t.pos;

                tile = t;
            }
        }

        if (tile != null)
            return tile.pos;

        return prev;
    }
</Code>

What these methods essentially do is check if whether or not a unit in the SpawnData is considered a Boss, then finds the corresponding spawner and returns it as the spawn point.

Since the spawned unit can be any one of: a named unit, a boss unit, or a generic unit; I had to check each spawner in such order to find the correct spawn location.

If the spawner has a specified ID which matches the unit's ID then we return that spawner immediately. If the spawning unit has a boss tag and the spawner is marked as a boss location then we also return that tile, provided if that tile has not yet been occupied. Otherwise, we simply find the last available tile and spawn the generic units there. This process makes sure named units and boss units are allocated correctly, eradicating any possibility of generic units spawning in incorrect locations.



<CodeLanguage="CSharp">
public Tile FindValidTile(Point position, int threshold = 100, Tile source = null)
    {
        Tile tile = null;

        tile = GetTile(position);
        if (!tile)
            return null;

        Tile t = tile;

        if (t.occupant == null)
            return tile;

        // Start A* Search
        ClearSearch();
        List<Tile> search = new List<Tile>();
        search.Add(t);

        Queue<Tile> checkNext = new Queue<Tile>();
        Queue<Tile> checkCurr = new Queue<Tile>();

        t.distance = 0;
        checkCurr.Enqueue(t);

        while (checkCurr.Count > 0)
        {
            t = checkCurr.Dequeue();

            for (int i = 0; i < 4; i++)
            {
                Tile next = GetTile(t.pos + directions[i]);

                if (next == null)
                    continue;
                if (next.stats.type == TileTypes.Wall || next.stats.type == TileTypes.Impasse)
                    continue;
                if (search.Contains(next) && next.distance <= t.distance + 1)
                    continue;

                // Found
                if (next.occupant == null || next.occupant == source)
                {
                    tile = next;
                    return tile;
                }

                // Not found
                if (t.distance + 1 <= threshold)
                {
                    next.distance = t.distance + 1;
                    next.prev = t;
                    checkNext.Enqueue(next);
                    search.Add(next);
                }
            }

            if (checkCurr.Count == 0)
            {
                Swap(ref checkCurr, ref checkNext);
            }
        }

        return tile;
    }
</Code>

The FindValidTile() method uses an A* algorithm which searches around the starting position in four directions (north, south, east, and west) for a valid tile closest to it. A valid tile is one that is unoccupied and traversable by a unit (since there are only 2 types of movement currently in the game: Ground, and Flying, I have no need to do additional checks for movement).


Finally, after we have determined whether or not the spawn location is valid, we create the units with the following method:

<CodeLanguage="CSharp">
Team InstantiateTeam(TeamSpawnData data, Tile target, Alliances alliance, bool loadSavedData = true)
    {
        GameObject obj = new GameObject("Team_");
        obj.transform.SetParent(this.transform);
        Team team = obj.AddComponent<Team>();

        // Instantiate all units and add to team
        List<Unit> unitsCreated = new List<Unit>();
        for (int i = 0; i < data.units.Count; i++)
        {
            GameObject uobj = Instantiate(data.units[i].unit);
            Unit unit = uobj.GetComponent<Unit>();
            uobj.transform.SetParent(obj.transform);

            unit.ID = data.units[i].ID;
            unit.Init();

            // Add unit to unit lists
            units[alliance].Add(unit);

            // Unit Size . . .

            // Set up units' specifics
            SetCharacterProfiles(data.units[i].characterProfile, unit, alliance);

            SetStats(data.units[i].statsProfile, unit);

            LoadSavedData(unit, loadSavedData);

            LoadSkills(unit);

            unit.stats.EvaluateStats();

            unitsCreated.Add(unit);
        }

        // Use method created in Team class
        StartCoroutine(team.CreateTeam(unitsCreated, target, false));

        return team;
    }
</Code>

This method simply returns a Team if a team is successfully created. If a team is successfully created, we add it to the Dictionary of teams in the UnitManager, and its units to the units Dictionary.

I also divided the creation process of units into a couple of steps:

For each UnitData in TeamSpawnData we Instantiate the Unit prefab and link its transform to the Team object's transform.

<CodeLanguage="CSharp">
GameObject uobj = Instantiate(data.units[i].unit);
Unit unit = uobj.GetComponent<Unit>();
uobj.transform.SetParent(obj.transform);
</Code>

Then we set the ID for the unit, initialize it, and add it to the dictionary of units.

<CodeLanguage="CSharp">
unit.ID = data.units[i].ID;
unit.Init();
// Add unit to unit lists
units[alliance].Add(unit);
</Code>

Then we set up the unit's specific information such as name, alliance, and whether or not the unit can be controlled by the player. This method will be extended upon and cleaned up even more when more things are added, but as of right now it primarily sets the character's identity and changes UI colors to match with that of the alliance it belongs to.

<CodeLanguage="CSharp">
void SetCharacterProfiles(CharacterProfile profile, Unit unit, Alliances alliance)
    {
        unit.profile.SetProfile(profile);

        // Set alliance
        unit.alliance.alliance = alliance;
        unit.graphics.UpdateColors();

        // Set driver
        unit.driver.normal = (alliance == Alliances.Player) ? Drivers.Human : Drivers.Computer;    

        // Set facing
        switch (alliance)
        {
            case Alliances.Enemy:
            case Alliances.Neutral:
                unit.animationController.sr.flipX = true;
                break;
        }
    }
</Code>

Then we set all the stats for the unit based on a provided table of stats (which I won't be going over since it is just a long list of numbers)


<CodeLanguage="CSharp">
SetStats(data.units[i].statsProfile, unit);
</Code>

Then the remaning sections are loading the saved data for playable units, loading skills and items, and then finally adding the unit to the team object. Since I've already written a method inside the Team class for handling creating a team from an array of units, there isn't too much more to add to the UnitManager.


With that team creation is fairly complete.


My next post will be about some of the shaders I have written (some for sprites in 3D space). Until then.


 
 
 

Comentarios


  • Instagram

©2020. Anh Tri Nguyen.

bottom of page