Search Unity

Question Troubleshooting Hex Grid System & Pathfinding

Discussion in 'Scripting' started by saikyr, Apr 1, 2023.

  1. saikyr

    saikyr

    Joined:
    Dec 10, 2019
    Posts:
    2
    I'm working on an auto-battler game that uses a hexagonal grid for movement and I'm running into an issue. Currently, units are sometimes able to move to tiles that are not directly adjacent by walking along the edge of another tile and skipping a tile. This also affects my attack range calculations, allowing units to attack from further than they should be able to.

    Images:

    #1. Unit walking along the edge of a tile (red line) vs. across the edge as intended (green lines). The green lines are normal movement, red is the issue I want to eliminate.


    #2. Unit attacking along a similar edge as above, even though the Unit's attack range is 1 and so should only be attacking adjacent tiles. For some reason, it's counting this as 1 attack range even though it shouldn't be reachable in 1 tile (should be 2). The green lines show normal attack positions, and the red line is an example of the issue where the unit can attack along the edge and count that as 1 range.


    I've tried modifying my GetNeighborTiles and HexDistance methods without success. I think the issue likely has something to do with my Unit code and the way it handles the path returned from TileManager, but I haven't been able to solve this after hours of trying different things.

    For additional context, I have a GameController class that is randomly spawning two teams of Units into this hex-based grid, and then the Unit class handles the combat logic. The issue seems to happen more frequently proportionally with the number of units in the battle (1v1 is usually fine, 3v3 will usually have a few of the units with the bug described, etc)

    Can someone help me figure out what I'm doing wrong? I would really appreciate any help I can get. Thank you in advance!

    Here's my current implementation:

    HexTileManager.cs:
    Code (CSharp):
    1. using System.Collections.Generic;
    2. using UnityEngine;
    3.  
    4. public class HexTileManager : MonoBehaviour
    5. {
    6.     public GameObject tilePrefab;
    7.     public int gridSize = 8; // used for both dimensions of the grid
    8.  
    9.     public Tile[,] tiles;
    10.  
    11.     private void Awake()
    12.     {
    13.         GenerateGrid();
    14.     }
    15.  
    16.     private void GenerateGrid()
    17.     {
    18.         tiles = new Tile[gridSize, gridSize];
    19.  
    20.         GameObject tempTileGO = Instantiate(tilePrefab, Vector3.zero, Quaternion.identity);
    21.         Mesh tempTileMesh = tempTileGO.GetComponentInChildren<MeshFilter>().sharedMesh;
    22.         float outerRadius = tempTileMesh.bounds.extents.z;
    23.         float innerRadius = outerRadius * 0.866025404f;
    24.         Destroy(tempTileGO);
    25.  
    26.         for (int q = 0; q < gridSize; q++)
    27.         {
    28.             for (int r = 0; r < gridSize; r++)
    29.             {
    30.                 float xPos,
    31.                     zPos;
    32.  
    33.                 if (r % 2 == 0)
    34.                 {
    35.                     xPos = q * (innerRadius * 2f);
    36.                     zPos = r * (outerRadius * 1.5f);
    37.                 }
    38.                 else
    39.                 {
    40.                     xPos = (q * (innerRadius * 2f)) + innerRadius;
    41.                     zPos = r * (outerRadius * 1.5f);
    42.                 }
    43.  
    44.                 GameObject tileGO = Instantiate(
    45.                     tilePrefab,
    46.                     new Vector3(xPos, 0f, zPos),
    47.                     Quaternion.identity
    48.                 );
    49.                 tileGO.name = $"Tile ({q}, {r})";
    50.                 tileGO.transform.parent = transform;
    51.  
    52.                 Tile tile = tileGO.GetComponent<Tile>();
    53.                 tile.q = q;
    54.                 tile.r = r;
    55.                 tile.s = -q - r;
    56.  
    57.                 tiles[q, r] = tile;
    58.             }
    59.         }
    60.     }
    61.  
    62.     public Tile GetTileAt(int q, int r)
    63.     {
    64.         if (q >= 0 && q < gridSize && r >= 0 && r < gridSize)
    65.         {
    66.             return tiles[q, r];
    67.         }
    68.         return null;
    69.     }
    70.  
    71.     public List<Tile> FindPath(Tile startTile, Unit targetUnit)
    72.     {
    73.         Tile endTile = targetUnit.currentTile;
    74.         Debug.Log(
    75.             $"Searching path from ({startTile.q}, {startTile.r}) to ({endTile.q}, {endTile.r})"
    76.         );
    77.  
    78.         List<Tile> openList = new List<Tile>();
    79.         HashSet<Tile> closedSet = new HashSet<Tile>();
    80.         Dictionary<Tile, Tile> cameFrom = new Dictionary<Tile, Tile>();
    81.         Dictionary<Tile, int> gScore = new Dictionary<Tile, int>();
    82.         Dictionary<Tile, int> fScore = new Dictionary<Tile, int>();
    83.  
    84.         openList.Add(startTile);
    85.         gScore[startTile] = 0;
    86.         fScore[startTile] = HexDistance(startTile, endTile);
    87.  
    88.         while (openList.Count > 0)
    89.         {
    90.             Tile currentTile = openList[0];
    91.  
    92.             for (int i = 1; i < openList.Count; i++)
    93.             {
    94.                 if (fScore.ContainsKey(openList[i]) && fScore[openList[i]] < fScore[currentTile])
    95.                 {
    96.                     currentTile = openList[i];
    97.                 }
    98.             }
    99.  
    100.             openList.Remove(currentTile);
    101.             closedSet.Add(currentTile);
    102.  
    103.             if (currentTile == endTile)
    104.             {
    105.                 List<Tile> path = new List<Tile>();
    106.                 path.Add(endTile);
    107.                 Tile parentTile = cameFrom[endTile];
    108.  
    109.                 while (parentTile != startTile)
    110.                 {
    111.                     path.Insert(0, parentTile);
    112.                     parentTile = cameFrom[parentTile];
    113.                 }
    114.  
    115.                 path.Insert(0, startTile);
    116.                 Debug.Log($"Found path with {path.Count} tiles:");
    117.                 foreach (Tile tile in path)
    118.                 {
    119.                     Debug.Log($"({tile.q}, {tile.r})");
    120.                 }
    121.                 return path;
    122.             }
    123.  
    124.             foreach (Tile neighborTile in GetNeighborTiles(currentTile, targetUnit))
    125.             {
    126.                 if (closedSet.Contains(neighborTile))
    127.                 {
    128.                     continue;
    129.                 }
    130.  
    131.                 int tentativeGScore = gScore[currentTile] + HexDistance(currentTile, neighborTile);
    132.  
    133.                 if (!openList.Contains(neighborTile) || tentativeGScore < gScore[neighborTile])
    134.                 {
    135.                     cameFrom[neighborTile] = currentTile;
    136.                     gScore[neighborTile] = tentativeGScore;
    137.                     fScore[neighborTile] = tentativeGScore + HexDistance(neighborTile, endTile);
    138.  
    139.                     if (!openList.Contains(neighborTile))
    140.                     {
    141.                         openList.Add(neighborTile);
    142.                     }
    143.                 }
    144.             }
    145.         }
    146.  
    147.         Debug.Log(
    148.             $"Could not find path from ({startTile.q}, {startTile.r}) to ({endTile.q}, {endTile.r})"
    149.         );
    150.         return null;
    151.     }
    152.  
    153.     public int GetAttackDistance(Tile startTile, Unit targetUnit)
    154.     {
    155.         return HexDistance(startTile, targetUnit.currentTile);
    156.     }
    157.  
    158.     public int HexDistance(Tile a, Tile b)
    159.     {
    160.         int deltaQ = Mathf.Abs(a.q - b.q);
    161.         int deltaR = Mathf.Abs(a.r - b.r);
    162.         int deltaS = Mathf.Abs(a.s - b.s);
    163.  
    164.         return Mathf.Max(deltaQ, deltaR, deltaS);
    165.     }
    166.  
    167.     private List<Tile> GetNeighborTiles(Tile tile, Unit targetUnit)
    168.     {
    169.         List<Tile> neighborTiles = new List<Tile>();
    170.  
    171.         int[][] edgeOffsets = new int[][]
    172.         {
    173.             new int[] { 1, 0 },
    174.             new int[] { -1, 0 },
    175.             new int[] { 0, 1 },
    176.             new int[] { 0, -1 },
    177.             new int[] { -1, 1 },
    178.             new int[] { 1, -1 }
    179.         };
    180.  
    181.         foreach (int[] offset in edgeOffsets)
    182.         {
    183.             Tile neighborTile = GetTileAt(tile.q + offset[0], tile.r + offset[1]);
    184.             if (
    185.                 neighborTile != null
    186.                 && (
    187.                     (neighborTile.IsEmpty() && !neighborTile.isLocked)
    188.                     || neighborTile.GetOccupyingUnit() == targetUnit
    189.                 )
    190.             )
    191.             {
    192.                 neighborTiles.Add(neighborTile);
    193.             }
    194.         }
    195.  
    196.         return neighborTiles;
    197.     }
    198. }
    Tile.cs:
    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public class Tile : MonoBehaviour
    4. {
    5.     public int q; // The q axial coordinate of the tile
    6.     public int r; // The r axial coordinate of the tile
    7.     public int s; // The s axial coordinate of the tile
    8.     public bool isEmpty = true;
    9.     public bool isLocked;
    10.     public Unit occupyingUnit;
    11.  
    12.     private Material normalMaterial;
    13.     public Material highlightedMaterial;
    14.  
    15.     private MeshRenderer meshRenderer;
    16.  
    17.     private void Awake()
    18.     {
    19.         meshRenderer = GetComponent<MeshRenderer>();
    20.         normalMaterial = meshRenderer.material;
    21.     }
    22.  
    23.     public bool IsEmpty()
    24.     {
    25.         return isEmpty;
    26.     }
    27.  
    28.     public void SetEmpty()
    29.     {
    30.         isEmpty = true;
    31.         occupyingUnit = null;
    32.     }
    33.  
    34.     public void SetUnit(Unit unit)
    35.     {
    36.         isEmpty = false;
    37.         occupyingUnit = unit;
    38.     }
    39.  
    40.     public Unit GetOccupyingUnit()
    41.     {
    42.         return occupyingUnit;
    43.     }
    44.  
    45.     public void LockTile()
    46.     {
    47.         isLocked = true;
    48.     }
    49.  
    50.     public void UnlockTile()
    51.     {
    52.         isLocked = false;
    53.     }
    54.  
    55.     private void OnMouseEnter()
    56.     {
    57.         meshRenderer.material = highlightedMaterial;
    58.     }
    59.  
    60.     private void OnMouseExit()
    61.     {
    62.         meshRenderer.material = normalMaterial;
    63.     }
    64. }
    Unit.cs
    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4.  
    5. public class Unit : MonoBehaviour
    6. {
    7.     public enum UnitState
    8.     {
    9.         Idle,
    10.         Moving,
    11.         Attacking
    12.     }
    13.  
    14.     public UnitState state;
    15.     public Unit target;
    16.  
    17.     public int team = 0;
    18.     public float moveSpeed = 4f;
    19.     public float rotateSpeed = 4f;
    20.     public int attackRange = 1; // Set the attack range in tiles
    21.     public float attackCooldown = 1f; // Set the cooldown duration in seconds
    22.     private float attackTimer;
    23.     private bool readyToAttack = true;
    24.     public int maxHealth = 10;
    25.     public int attackDamage = 2;
    26.     private int currentHealth;
    27.  
    28.     public HexTileManager tileManager;
    29.     public Tile currentTile;
    30.     List<Tile> path;
    31.     private Coroutine moveCoroutine;
    32.     private Coroutine attackCoroutine;
    33.  
    34.     private Animator animator;
    35.  
    36.     private void Awake()
    37.     {
    38.         animator = GetComponent<Animator>();
    39.         currentHealth = maxHealth;
    40.     }
    41.  
    42.     private void Start()
    43.     {
    44.         state = UnitState.Idle;
    45.     }
    46.  
    47.     private void Update()
    48.     {
    49.         if (target == null)
    50.         {
    51.             state = UnitState.Idle;
    52.         }
    53.  
    54.         // Update the attack timer
    55.         if (!readyToAttack)
    56.         {
    57.             attackTimer -= Time.deltaTime;
    58.             if (attackTimer <= 0)
    59.             {
    60.                 readyToAttack = true;
    61.             }
    62.         }
    63.  
    64.         switch (state)
    65.         {
    66.             case UnitState.Idle:
    67.                 HandleIdleState();
    68.                 break;
    69.             case UnitState.Moving:
    70.                 HandleMovingState();
    71.                 break;
    72.             case UnitState.Attacking:
    73.                 HandleAttackingState();
    74.                 break;
    75.         }
    76.     }
    77.  
    78.     private void HandleIdleState()
    79.     {
    80.         if (target == null)
    81.         {
    82.             FindNearestEnemy();
    83.         }
    84.         else
    85.         {
    86.             state = UnitState.Moving;
    87.         }
    88.     }
    89.  
    90.     private void HandleMovingState()
    91.     {
    92.         // Calculate the distance to the target in tiles
    93.         int distance = tileManager.GetAttackDistance(currentTile, target);
    94.  
    95.         // If target is within attack range, switch to attacking state
    96.         if (distance <= attackRange)
    97.         {
    98.             state = UnitState.Attacking;
    99.         }
    100.         // Otherwise move 1 tile closer to the target
    101.         else
    102.         {
    103.             MoveToTarget();
    104.         }
    105.     }
    106.  
    107.     private void HandleAttackingState()
    108.     {
    109.         // Calculate the distance to the target in tiles
    110.         int distance = tileManager.GetAttackDistance(currentTile, target);
    111.  
    112.         Debug.Log("enemy unit is " + distance + " tiles away, my range is " + attackRange);
    113.         // If target is within attack range, attack
    114.         if (distance <= attackRange)
    115.         {
    116.             AttackTarget();
    117.         }
    118.         // Otherwise switch to idle to reset
    119.         else
    120.         {
    121.             state = UnitState.Idle;
    122.         }
    123.     }
    124.  
    125.     public void MoveToTarget()
    126.     {
    127.         if (moveCoroutine == null && attackCoroutine == null)
    128.         {
    129.             moveCoroutine = StartCoroutine(MoveAlongPath());
    130.         }
    131.     }
    132.  
    133.     private IEnumerator MoveAlongPath()
    134.     {
    135.         Tile startTile = currentTile;
    136.         path = tileManager.FindPath(startTile, target);
    137.  
    138.         if (path != null && path.Count > 1)
    139.         {
    140.             Tile nextTile = path[1];
    141.             Vector3 targetPosition = nextTile.transform.position;
    142.  
    143.             // Break if next tile is occupied
    144.             if (!nextTile.isEmpty)
    145.             {
    146.                 state = UnitState.Idle;
    147.                 // Setting coroutine to null to allow next action
    148.                 moveCoroutine = null;
    149.                 Debug.Log("coroutine broken cause next tile occupied.");
    150.                 yield break;
    151.             }
    152.  
    153.             // Break if the next tile is locked
    154.             if (nextTile.isLocked)
    155.             {
    156.                 state = UnitState.Idle;
    157.                 // Setting coroutine to null to allow next action
    158.                 moveCoroutine = null;
    159.                 Debug.Log("coroutine broken cause next tile locked.");
    160.                 yield break;
    161.             }
    162.  
    163.             // Lock the next tile before moving to it
    164.             nextTile.LockTile();
    165.  
    166.             // Set animator bool
    167.             animator.SetBool("isMoving", true);
    168.  
    169.             // Move to tile
    170.             while (Vector3.Distance(transform.position, targetPosition) > 0.01f)
    171.             {
    172.                 transform.position = Vector3.MoveTowards(
    173.                     transform.position,
    174.                     targetPosition,
    175.                     moveSpeed * Time.deltaTime
    176.                 );
    177.                 Quaternion targetRotation = Quaternion.LookRotation(
    178.                     targetPosition - transform.position,
    179.                     Vector3.up
    180.                 );
    181.                 transform.rotation = Quaternion.Lerp(
    182.                     transform.rotation,
    183.                     targetRotation,
    184.                     rotateSpeed * Time.deltaTime
    185.                 );
    186.  
    187.                 yield return null;
    188.             }
    189.  
    190.             // Set animator bool
    191.             animator.SetBool("isMoving", false);
    192.  
    193.             // Setting coroutine to null to allow next action
    194.             moveCoroutine = null;
    195.  
    196.             transform.position = targetPosition;
    197.  
    198.             // Update the current tile of the unit
    199.             currentTile.SetEmpty();
    200.             currentTile.UnlockTile();
    201.             currentTile = nextTile;
    202.             currentTile.SetUnit(this);
    203.         }
    204.         else
    205.         {
    206.             Debug.Log("path not found");
    207.             state = UnitState.Idle;
    208.         }
    209.     }
    210.  
    211.     private void FindNearestEnemy()
    212.     {
    213.         float minDistance = float.MaxValue;
    214.         Unit nearestEnemy = null;
    215.  
    216.         List<Unit> enemyUnits = GameManager.instance.GetTeamUnits(team == 0 ? 1 : 0);
    217.         foreach (Unit enemyUnit in enemyUnits)
    218.         {
    219.             List<Tile> path = tileManager.FindPath(currentTile, enemyUnit);
    220.             if (path != null && path.Count < minDistance)
    221.             {
    222.                 minDistance = path.Count;
    223.                 nearestEnemy = enemyUnit;
    224.             }
    225.         }
    226.  
    227.         if (nearestEnemy != null)
    228.         {
    229.             Debug.Log("Nearest enemy found: " + nearestEnemy.gameObject.name);
    230.             target = nearestEnemy;
    231.         }
    232.     }
    233.  
    234.     private void AttackTarget()
    235.     {
    236.         if (readyToAttack && attackCoroutine == null)
    237.         {
    238.             animator.SetTrigger("attack");
    239.  
    240.             // Damage target and return if unit is dead
    241.             if (target.TakeDamage(attackDamage))
    242.             {
    243.                 // Target unit was destroyed
    244.                 target = null;
    245.                 state = UnitState.Idle;
    246.             }
    247.  
    248.             attackCoroutine = StartCoroutine(WaitForAttackAnimation());
    249.  
    250.             // Set the unit as not ready to attack and reset the timer
    251.             readyToAttack = false;
    252.             attackTimer = attackCooldown;
    253.         }
    254.     }
    255.  
    256.     public bool TakeDamage(int damage)
    257.     {
    258.         currentHealth -= damage;
    259.         if (currentHealth <= 0)
    260.         {
    261.             OnDeath();
    262.             return true;
    263.         }
    264.         return false;
    265.     }
    266.  
    267.     public void OnDeath()
    268.     {
    269.         // Remove the unit from the current tile
    270.         currentTile.SetEmpty();
    271.         currentTile.UnlockTile();
    272.  
    273.         // Remove the unit from the game manager's list of units
    274.         GameManager.instance.RemoveUnit(this);
    275.  
    276.         // Set target to null for all units on the opposing team targeting this unit
    277.         List<Unit> enemyUnits = GameManager.instance.GetTeamUnits(team == 0 ? 1 : 0);
    278.         foreach (Unit enemyUnit in enemyUnits)
    279.         {
    280.             if (enemyUnit.target == this)
    281.             {
    282.                 enemyUnit.target = null;
    283.             }
    284.         }
    285.  
    286.         // Destroy the game object
    287.         Destroy(gameObject);
    288.     }
    289.  
    290.     private IEnumerator WaitForAttackAnimation()
    291.     {
    292.         transform.LookAt(target.transform.position);
    293.  
    294.         // Wait for the exact duration of the attack animation
    295.         yield return new WaitForSeconds(animator.GetCurrentAnimatorStateInfo(0).length);
    296.  
    297.         state = UnitState.Idle;
    298.  
    299.         // Setting coroutine to null to allow next action
    300.         attackCoroutine = null;
    301.     }
    302. }
    303.  
     
    Last edited: Apr 1, 2023