Rutas A* tácticas en Unity

Crea un tablero táctico en Unity con A*, costes y movimiento por clic.

Introduction

Resumen en 30 Segundos

En un juego táctico, una ruta corta puede acabar frente a un muro o cruzar terreno que consume demasiados puntos. La mejor decisión depende del coste real de llegar.

En este proyecto, construirás en Unity un prototipo táctico que usa A* para encontrar rutas por una cuadrícula. El algoritmo comparará obstáculos y costes para elegir el recorrido.

Qué Construirás

Al pulsar Play, verás una esfera azul avanzar casilla por casilla por la ruta que acabas de confirmar sobre un tablero táctico.

Al terminar este proyecto, tendrás:

  • Un tablero táctico generado por código que aparece completo al entrar en Play Mode. Sus muros y tipos de terreno se distinguen por color.
  • Una vista previa de rutas A* que aparece al seleccionar un destino. El camino evita muros y compara el coste del barro.
  • Un movimiento con confirmación que hace avanzar la unidad casilla por casilla dentro del presupuesto mostrado en el HUD.
  • Secret Mission: una unidad enemiga que reutiliza el mismo pathfinder para perseguir al jugador después de cada movimiento.

¿Necesito experiencia previa?

Este proyecto presupone experiencia básica con Unity y C#. La guía comprueba Unity 6.3 LTS, Visual Studio Community 2022 y Visual Studio Tools for Unity antes de empezar.

Before We Start

Before the hands-on work begins, this is your moment to lock in what the tactical prototype must prove. Your unit must avoid wall cells while choosing routes by total terrain cost, which means a longer route can still be the better route.

Set Up Unity and the Project

The tactical board needs a stable editor before its grid logic can run reliably. Tooling problems can otherwise look like faults in your future A* code.

You will confirm Unity Hub can provide Unity 6.3 LTS. You will confirm Visual Studio Community 2022 includes Visual Studio Tools for Unity.

You will finish with a saved scene that enters Play Mode without Console errors.

In this step, get ready to:
  • Confirm Unity 6.3 LTS is installed through Unity Hub.
  • Prepare Visual Studio Community 2022 for Unity scripts.
  • Create the GridPathfinder project with a saved test scene.
Check Unity and Visual Studio

Unity 6.3 LTS gives this project a stable editor baseline. The matching Visual Studio workload connects your scripts to the C# editor.

  • Press the Windows key to open Windows search.
  • Type Unity Hub into the search field.
  • Press Enter to open Unity Hub.
  • Select Installs in the left sidebar.

Compare the Editor versions in the Installs list with the situations below.

✔️ I see Unity 6.3 LTS

Great. Unity 6.3 LTS is installed for this project.

ⓧ I see another Unity version

Keep the existing Editor available for its current projects.

  • Stay in the Installs section of Unity Hub.
  • Start the flow for adding another Editor version.
  • Select Unity 6.3 LTS.
  • Complete the Editor installation.

Unity 6.3 LTS now appears beside your existing version.

ⓧ No Editor version is installed

Unity Hub is ready to manage the Editor installation.

  • Start the Editor installation flow from Installs.
  • Select Unity 6.3 LTS.
  • Complete the Editor installation.

Unity 6.3 LTS now appears in the Installs list.

The Editor download can take several minutes if you need to add it. A long installation progress phase is normal.

  • Press the Windows key to return to Windows search.
  • Type Visual Studio Installer into the search field.
  • Press Enter to open Visual Studio Installer.

Find Visual Studio Community 2022 in the installer. Choose the situation that matches what you see.

✔️ The Unity workload is installed

The Game development with Unity workload is available. Your C# scripts can open with Unity project support.

ⓧ The Unity workload is missing

Visual Studio Community 2022 is installed without the Unity workload.

  • Select Modify for Visual Studio Community 2022.
  • Select Game development with Unity in the Workloads view.
  • Apply the modification.
  • Wait until the installer reports completion.

The Unity workload now appears selected for Visual Studio Community 2022.

ⓧ Visual Studio Community 2022 is missing

Visual Studio Installer can add the required Community edition.

  • Choose Visual Studio Community 2022 in the installer.
  • Start the installation.
  • Select Game development with Unity in the Workloads view.
  • Complete the installation.

Visual Studio Community 2022 is now available with Visual Studio Tools for Unity.

Installer not showing the Unity workload?

  • Restart Visual Studio Installer if the edition list does not refresh.
  • Select the Workloads view before looking for Game development with Unity.

Still stuck? Help me prepare Visual Studio Community 2022 for Unity development.

Create the GridPathfinder project

The Universal 3D template gives you a 3D renderer for the cubes and units used by the tactical board. An orthographic camera later presents those objects as a clear grid.

  • Return to Unity Hub from earlier.
  • Complete Unity's sign-in flow if Hub requests authentication.
  • Return to Unity Hub after the browser confirms your sign-in.
  • Select Projects in the left sidebar.
  • Select New project.
  • Set the Editor version to Unity 6.3 LTS.
  • Select Universal 3D as the template.
  • Enter GridPathfinder in the Project name field.

The first project launch can take several minutes while Unity creates the project files. A quiet import progress bar is normal.

  • Select Create project to build the project.

The Unity Editor opens with the GridPathfinder project loaded. That confirms the Universal 3D project exists.

  • Complete the editor sign-in flow if Unity requests authentication.

Why Universal 3D?

The game uses two-dimensional movement rules. Its board still uses cubes plus a sphere for readable height and colour.

Universal 3D supplies the renderer needed for those objects without requiring external art assets.

The project reads one left mouse click through Input.GetMouseButtonDown(0). The Active Input Handling setting determines whether that click reaches the game.

  • Open Edit in the Unity menu bar.
  • Select Project Settings.
  • Select Player in the settings sidebar.
  • Expand Other Settings.
  • Find the Configuration section.
  • Set Active Input Handling to Both.
  • Accept the editor restart if Unity requests one.
  • Wait for GridPathfinder to reopen.

The Player settings now show Both for Active Input Handling. The project can use the focused click API without conflicting with the newer Input System.

Save the scene and connect the scripts

A GameObject can hold the MonoBehaviour that controls the tactical prototype. The saved scene preserves that component connection.

The two script files are scaffolds at this checkpoint. Later steps fill them with the board and pathfinding logic.

  • Open File in the Unity menu bar.
  • Select Save Scene.
  • Create a folder named Scenes inside Assets in the save dialog.
  • Open the Scenes folder.
  • Enter GridStrategy as the scene name.
  • Select Save.

The open scene is now saved at Assets/Scenes/GridStrategy.unity.

  • Right-click the Assets folder in the Project window.
  • Select Create.
  • Select Folder.
  • Type Scripts as the folder name.
  • Press Enter to create the folder.

You will see Scenes plus Scripts under Assets in the Project window.

  • Open Edit in the Unity menu bar.
  • Select Preferences.
  • Select External Tools.
  • Set External Script Editor to Visual Studio.

Unity now sends C# files to Visual Studio when you double-click them.

  • Select the Assets/Scripts folder in the Project window.
  • Open Assets in the Unity menu bar.
  • Select Create.
  • Select Scripting.
  • Create a MonoBehaviour script named GridStrategyGame.
  • Double-click GridStrategyGame.cs in the Project window.

Visual Studio opens GridStrategyGame.cs in an editor tab. The matching class can be attached to a GameObject.

  • Return to the Unity Editor.
  • Use the Scripting creation menu from earlier to create Pathfinder.cs inside Assets/Scripts.
  • Double-click Pathfinder.cs in the Project window.
  • Change its class declaration to public sealed class Pathfinder in Visual Studio.
  • Press Ctrl+S to save Pathfinder.cs.

Visual Studio now has GridStrategyGame.cs plus Pathfinder.cs open. The Pathfinder file declares a sealed class for the later A* implementation.

Unity may spend a few seconds compiling after the script save. The editor is ready when its compilation indicator finishes.

  • Return to the Unity Editor.
  • Wait for script compilation to finish.
  • Open GameObject in the Unity menu bar.
  • Select Create Empty.
  • Rename the new object to GameManager in the Hierarchy.
  • Drag GridStrategyGame.cs from the Project window onto GameManager in the Hierarchy.
  • Select GameManager in the Hierarchy.

The Inspector shows the GridStrategyGame component on GameManager. You have connected the saved scene to its first script component.

Your setup checkpoint has three project artifacts.

✔️ Awesome, I've got everything!

Your scene is saved. Both scripts exist in Visual Studio.

The GridStrategyGame component is attached to GameManager.

ⓧ I'd like to double check the full code

Assets/Scenes/GridStrategy.unity is a saved Unity scene containing a GameObject named GameManager with the GridStrategyGame component attached.

Assets/Scripts/GridStrategyGame.cs is a C# MonoBehaviour script file created for GridStrategyGame. It is attached to GameManager.

Assets/Scripts/Pathfinder.cs is a C# script file created for the sealed Pathfinder class. It will contain the A* pathfinding implementation.

Before you press Play, consider whether the saved scene will enter Play Mode without a Console error.

  • Press Ctrl+S to save GridStrategy.unity.
  • Select the Console tab in Unity.
  • Press the Play button at the top of the editor.

You will see the editor enter Play Mode. The Console stays free of red errors.

That clears the toolchain checkpoint. Your scene can now host the generated tactical board.

  • Press the Play button again to exit Play Mode.

Your Unity project is ready for gameplay code. Next up, you will generate the complete tactical board from C#.

Generate the Tactical Board

Your Unity project already enters Play Mode without errors. You now have a stable scene where code can generate the tactical world.

An A* pathfinder needs a visible map that connects each cell index to terrain and a scene object. This step builds that connection before any route search begins.

In this step, get ready to:
  • Define the dimensions and terrain data for a 10 by 8 grid.
  • Generate 80 cube tiles with distinct colors for normal terrain, mud, and walls.
  • Frame the board with an orthographic camera and place a blue player unit.
Define the grid data

A discrete grid represents every position with one integer cell index. Dictionaries connect those indices to cubes while sets record which cells contain walls or mud.

  • Switch back to GridStrategyGame.cs in Visual Studio.
  • Replace the existing contents of GridStrategyGame.cs with this class foundation:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GridStrategyGame : MonoBehaviour
{
    private const int Width = 10;
    private const int Height = 8;
    private const int MovementBudget = 18;

    private readonly Dictionary<int, GameObject> tiles = new Dictionary<int, GameObject>();
    private readonly Dictionary<GameObject, int> cellsByTile = new Dictionary<GameObject, int>();
    private readonly HashSet<int> walls = new HashSet<int>();
    private readonly HashSet<int> mud = new HashSet<int>();

    public bool useTerrainCosts = true;

    private Pathfinder pathfinder;
    private Camera gameCamera;
    private GameObject playerUnit;
    private List<int> previewPath = new List<int>();
    private int playerCell;
    private bool moving;
    private string status = "Haz clic en una casilla para calcular una ruta.";
}

What does this code establish?

  • The constants fix the board at 10 columns and 8 rows.
  • The tiles dictionary retrieves a cube from its cell index.
  • The cellsByTile dictionary provides the reverse lookup from a clicked cube to its index.
  • The walls and mud sets classify terrain without duplicating scene objects.
  • The remaining fields hold the pathfinder, camera, player, route preview, and movement state.
  • Save GridStrategyGame.cs.
  • Switch back to the Unity editor.
  • Select GameManager in the Hierarchy window.
  • Confirm that the useTerrainCosts checkbox appears in the Inspector window.
  • Confirm that the Console window has no red compilation errors.

That first compile is complete. GameManager now holds the data needed to represent the whole board.

Is the component missing its field?

Confirm that the file name remains GridStrategyGame.cs. The public class name must remain GridStrategyGame so Unity can keep the component attached.

If Unity reports a compilation problem, compare the braces and field declarations with the code above. Help me fix the GridStrategyGame class foundation.

The board controller creates a Pathfinder instance when Play Mode begins. A small class shell keeps that dependency valid until the next step adds the search algorithm.

  • Switch to Pathfinder.cs in Visual Studio.
  • Replace its contents with this class shell:
using System.Collections.Generic;

public sealed class Pathfinder
{
    private static readonly int[] DeltaX = { 1, -1, 0, 0 };
    private static readonly int[] DeltaY = { 0, 0, 1, -1 };

    private readonly int width;
    private readonly int height;
    private readonly HashSet<int> blockedCells;
    private readonly HashSet<int> costlyCells;

    public Pathfinder(
        int width,
        int height,
        HashSet<int> blockedCells,
        HashSet<int> costlyCells)
    {
        this.width = width;
        this.height = height;
        this.blockedCells = blockedCells;
        this.costlyCells = costlyCells;
    }
}

What does the pathfinder shell hold?

  • The DeltaX and DeltaY arrays describe the four orthogonal directions.
  • The constructor receives the grid dimensions and both terrain sets.
  • The class stores those inputs for the A* search added in the next step.
  • Save Pathfinder.cs.
  • Return to the Unity editor.
  • Wait for Unity to finish compiling the scripts.
  • Confirm that the Console window remains free of red errors.

Does Pathfinder fail to compile?

Check that using System.Collections.Generic; appears at the top of Pathfinder.cs. The terrain fields use generic collections from that namespace.

Keep the constructor parameters in the same order as the fields shown above. Help me troubleshoot the Pathfinder class shell.

Grid coordinates need two conversions. ToCell converts an x and y pair into one index while CellToWorld converts that index into a Unity position.

  • Switch back to GridStrategyGame.cs.
  • Insert these coordinate conversion methods immediately before the final closing brace:
    private int ToCell(int x, int y)
    {
        return y * Width + x;
    }

    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

How do the conversions work?

  • ToCell places each new row after the previous row's 10 cells.
  • The remainder operator recovers the x coordinate from a cell index.
  • Integer division recovers the y coordinate from a cell index.
  • The height parameter places units above the top surface of each cube.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that Unity completes the compilation without red errors.

Seeing a coordinate method error?

Confirm that both methods sit inside the GridStrategyGame class. They must appear before the class's final closing brace.

Check that Width uses a capital W in both methods. Help me fix the grid coordinate conversion methods.

The terrain layout is stored as cell indices. One barrier column forces future routes to use either the muddy opening or the clear opening at the top.

  • Insert ConfigureMap immediately before ToCell in GridStrategyGame.cs:
    private void ConfigureMap()
    {
        walls.Add(ToCell(4, 0));
        walls.Add(ToCell(4, 1));
        walls.Add(ToCell(4, 2));
        walls.Add(ToCell(4, 4));
        walls.Add(ToCell(4, 5));
        walls.Add(ToCell(4, 6));

        mud.Add(ToCell(2, 3));
        mud.Add(ToCell(3, 3));
        mud.Add(ToCell(4, 3));
        mud.Add(ToCell(5, 3));
        mud.Add(ToCell(6, 3));
    }

What terrain does this create?

  • The wall occupies column 4 at rows 0 through 2.
  • The wall continues through rows 4 through 6.
  • Row 3 stays open through the barrier.
  • Row 7 provides a second opening above the barrier.
  • The five muddy cells occupy row 3 from column 2 through column 6.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that the script compiles without red errors.

Does ConfigureMap show an error?

Make sure ConfigureMap appears after the class fields. Keep every ToCell call inside the method braces.

A missing parenthesis can make several terrain lines appear broken at once. Help me debug the ConfigureMap method.

Generate the terrain cubes

Each logical cell needs a visual cube with a terrain color. The color helpers keep that decision separate from the loop that creates the 80 objects.

  • Insert these color helpers immediately before ToCell in GridStrategyGame.cs:
    private Color GetBaseColor(int cell)
    {
        if (walls.Contains(cell))
        {
            return new Color(0.2f, 0.22f, 0.25f);
        }

        if (mud.Contains(cell))
        {
            return new Color(0.45f, 0.25f, 0.08f);
        }

        return new Color(0.2f, 0.6f, 0.25f);
    }

    private void SetTileColor(int cell, Color color)
    {
        tiles[cell].GetComponent<Renderer>().material.color = color;
    }

How are terrain colors chosen?

  • Wall cells receive a dark gray color.
  • Mud cells receive a brown color.
  • Every remaining cell receives a green color.
  • SetTileColor retrieves the cube renderer before assigning its material color.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that Unity finishes compiling without red errors.

Seeing an error in the color helpers?

Check that GetBaseColor and SetTileColor appear inside the class. Keep each decimal value followed by the lowercase f suffix.

Confirm that Renderer uses a capital R. Help me troubleshoot the terrain color helpers.

Unity primitives include a visible mesh and a collider. The collider becomes important when mouse clicks identify tiles in the next step.

  • Insert BuildBoard immediately before GetBaseColor in GridStrategyGame.cs:
    private void BuildBoard()
    {
        for (int y = 0; y < Height; y++)
        {
            for (int x = 0; x < Width; x++)
            {
                int cell = ToCell(x, y);
                GameObject tile = GameObject.CreatePrimitive(PrimitiveType.Cube);
                tile.name = "Tile " + x + "," + y;

                bool isWall = walls.Contains(cell);
                tile.transform.position = new Vector3(x, isWall ? 0.3f : 0f, y);
                tile.transform.localScale = new Vector3(0.92f, isWall ? 0.7f : 0.12f, 0.92f);

                tiles.Add(cell, tile);
                cellsByTile.Add(tile, cell);
                SetTileColor(cell, GetBaseColor(cell));
            }
        }
    }

What does BuildBoard create?

  • The nested loops visit all 80 coordinate pairs.
  • GameObject.CreatePrimitive creates one cube for each pair.
  • Each cube receives a coordinate-based name such as Tile 2,3.
  • Walls become taller than normal tiles so the barrier reads clearly.
  • Both dictionaries store the relationship between each cell index and its cube.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that the Console window remains free of red compilation errors.

Does BuildBoard fail to compile?

Check that BuildBoard appears before GetBaseColor. Confirm that both nested loops have matching braces.

Keep the tile name string and both dictionary additions exactly as shown. Help me debug the BuildBoard method.

Frame the board and create the player

An orthographic camera removes perspective shrinkage. A top-down orientation keeps every grid cell the same apparent size.

  • Insert ConfigureCamera immediately before BuildBoard in GridStrategyGame.cs:
    private void ConfigureCamera()
    {
        gameCamera = Camera.main;

        if (gameCamera == null)
        {
            GameObject cameraObject = new GameObject("Runtime Camera", typeof(Camera));
            gameCamera = cameraObject.GetComponent<Camera>();
        }

        gameCamera.transform.position = new Vector3(
            (Width - 1) * 0.5f,
            10f,
            (Height - 1) * 0.5f);
        gameCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
        gameCamera.orthographic = true;
        gameCamera.orthographicSize = 5.2f;
    }

How does the camera frame the board?

  • Camera.main reuses the scene's main camera when one exists.
  • The fallback creates a camera named Runtime Camera when the scene has no main camera.
  • The position centers the camera over the 10 by 8 grid.
  • The 90 degree rotation points the camera straight down.
  • The orthographic size keeps the complete board inside the Game view.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that Unity completes the compilation without red errors.

Does the camera method show an error?

Confirm that Camera and Quaternion use capital letters. Keep the multiline position assignment inside one pair of parentheses.

Check that ConfigureCamera sits inside the class. Help me fix the orthographic camera method.

The player uses the same primitive-based approach as the board. Its shape, position, scale, and color come from one reusable method.

  • Insert CreateUnit immediately after CellToWorld in GridStrategyGame.cs:
    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }

What does CreateUnit control?

  • The shape parameter chooses the Unity primitive.
  • CellToWorld places the unit above its assigned cell.
  • The conditional scale gives spheres and other shapes distinct proportions.
  • The supplied color is assigned through the unit's renderer.
  • Save GridStrategyGame.cs.
  • Return to the Unity editor.
  • Confirm that the script compiles without red errors.

Does CreateUnit fail to compile?

Confirm that the method appears after CellToWorld. Keep the question mark and colon in the conditional scale expression.

Check that the method returns unit before its closing brace. Help me troubleshoot the CreateUnit method.

Unity calls Start before the first frame update. This is where the terrain data, pathfinder shell, camera, cubes, and blue sphere come together.

  • Insert Start immediately before ConfigureMap in GridStrategyGame.cs:
    private void Start()
    {
        ConfigureMap();
        pathfinder = new Pathfinder(Width, Height, walls, mud);
        ConfigureCamera();
        BuildBoard();

        playerCell = ToCell(1, 1);
        playerUnit = CreateUnit(new Color(0.15f, 0.45f, 1f), playerCell, PrimitiveType.Sphere);
    }

What happens when Play Mode starts?

  • ConfigureMap fills the wall and mud sets.
  • The constructor gives Pathfinder access to the grid dimensions and terrain sets.
  • ConfigureCamera prepares the top-down Game view.
  • BuildBoard creates and colors all 80 cubes.
  • The final two lines create the blue sphere at cell coordinates 1,1.
  • Save GridStrategyGame.cs.
  • Switch back to the Unity editor.

Before you press Play, predict whether the camera will show all 80 cells with the blue unit inside the board.

  • Click the Play button at the top of the Unity editor.

You should see a 10 by 8 board of cubes. Green tiles form the normal terrain while a gray barrier crosses column 4.

A brown mud strip crosses row 3 from column 2 through column 6. A blue sphere sits near the lower-left corner at cell 1,1.

  • Select the Console tab to confirm that Play Mode produced no red errors.

You have the first visual milestone: every logical terrain cell now has a matching object in the scene. The board is ready to become a searchable graph.

Is the board missing or incomplete?

  • Confirm that GridStrategyGame remains attached to GameManager.
  • Check that Start calls ConfigureMap before BuildBoard.
  • Check that the camera rotation uses 90 degrees on the x axis.
  • Confirm that the Game view uses the camera output while Play Mode is active.

If the board still does not match the expected layout, help me debug the generated Unity board.

✔️ Awesome, I've got everything!

Great work. Keep Play Mode running long enough to confirm that all 80 tiles and the blue player sphere are visible.

ⓧ I'd like to double check the full code

Compare your complete GridStrategyGame.cs with this version line by line.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GridStrategyGame : MonoBehaviour
{
    private const int Width = 10;
    private const int Height = 8;
    private const int MovementBudget = 18;

    private readonly Dictionary<int, GameObject> tiles = new Dictionary<int, GameObject>();
    private readonly Dictionary<GameObject, int> cellsByTile = new Dictionary<GameObject, int>();
    private readonly HashSet<int> walls = new HashSet<int>();
    private readonly HashSet<int> mud = new HashSet<int>();

    public bool useTerrainCosts = true;

    private Pathfinder pathfinder;
    private Camera gameCamera;
    private GameObject playerUnit;
    private List<int> previewPath = new List<int>();
    private int playerCell;
    private bool moving;
    private string status = "Haz clic en una casilla para calcular una ruta.";

    private void Start()
    {
        ConfigureMap();
        pathfinder = new Pathfinder(Width, Height, walls, mud);
        ConfigureCamera();
        BuildBoard();

        playerCell = ToCell(1, 1);
        playerUnit = CreateUnit(new Color(0.15f, 0.45f, 1f), playerCell, PrimitiveType.Sphere);
    }

    private void ConfigureMap()
    {
        walls.Add(ToCell(4, 0));
        walls.Add(ToCell(4, 1));
        walls.Add(ToCell(4, 2));
        walls.Add(ToCell(4, 4));
        walls.Add(ToCell(4, 5));
        walls.Add(ToCell(4, 6));

        mud.Add(ToCell(2, 3));
        mud.Add(ToCell(3, 3));
        mud.Add(ToCell(4, 3));
        mud.Add(ToCell(5, 3));
        mud.Add(ToCell(6, 3));
    }

    private void ConfigureCamera()
    {
        gameCamera = Camera.main;

        if (gameCamera == null)
        {
            GameObject cameraObject = new GameObject("Runtime Camera", typeof(Camera));
            gameCamera = cameraObject.GetComponent<Camera>();
        }

        gameCamera.transform.position = new Vector3(
            (Width - 1) * 0.5f,
            10f,
            (Height - 1) * 0.5f);
        gameCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
        gameCamera.orthographic = true;
        gameCamera.orthographicSize = 5.2f;
    }

    private void BuildBoard()
    {
        for (int y = 0; y < Height; y++)
        {
            for (int x = 0; x < Width; x++)
            {
                int cell = ToCell(x, y);
                GameObject tile = GameObject.CreatePrimitive(PrimitiveType.Cube);
                tile.name = "Tile " + x + "," + y;

                bool isWall = walls.Contains(cell);
                tile.transform.position = new Vector3(x, isWall ? 0.3f : 0f, y);
                tile.transform.localScale = new Vector3(0.92f, isWall ? 0.7f : 0.12f, 0.92f);

                tiles.Add(cell, tile);
                cellsByTile.Add(tile, cell);
                SetTileColor(cell, GetBaseColor(cell));
            }
        }
    }

    private Color GetBaseColor(int cell)
    {
        if (walls.Contains(cell))
        {
            return new Color(0.2f, 0.22f, 0.25f);
        }

        if (mud.Contains(cell))
        {
            return new Color(0.45f, 0.25f, 0.08f);
        }

        return new Color(0.2f, 0.6f, 0.25f);
    }

    private void SetTileColor(int cell, Color color)
    {
        tiles[cell].GetComponent<Renderer>().material.color = color;
    }

    private int ToCell(int x, int y)
    {
        return y * Width + x;
    }

    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }
}

Compare your complete Pathfinder.cs with this class shell.

using System.Collections.Generic;

public sealed class Pathfinder
{
    private static readonly int[] DeltaX = { 1, -1, 0, 0 };
    private static readonly int[] DeltaY = { 0, 0, 1, -1 };

    private readonly int width;
    private readonly int height;
    private readonly HashSet<int> blockedCells;
    private readonly HashSet<int> costlyCells;

    public Pathfinder(
        int width,
        int height,
        HashSet<int> blockedCells,
        HashSet<int> costlyCells)
    {
        this.width = width;
        this.height = height;
        this.blockedCells = blockedCells;
        this.costlyCells = costlyCells;
    }
}

Your tactical map now has logical cells, visible terrain, and a player position. Next, you will turn those cells into a graph that A* can search around the walls.

Find Paths Around Walls

Your generated board already connects each visible tile to a cell index. A direct line toward a destination would still cross the gray barrier.

This step adds A* search. The algorithm explores walkable neighbors until it can reconstruct a valid route around the walls.

In this step, get ready to:
  • Track candidate cells with open and closed collections.
  • Score four-direction routes with accumulated cost and Manhattan distance.
  • Convert mouse clicks into yellow route previews around the walls.
Store the search state

A* needs to remember which cells remain available for exploration. It also records the cheapest known cost and the predecessor of each discovered cell.

How does A* remember a search?

  • The open list contains discovered cells that still need evaluation.
  • The closed set contains cells that have already been evaluated.
  • The gCost map stores the cheapest known cost from the starting cell.
  • The cameFrom map stores the previous cell used to reach each cell.
  • Switch back to Visual Studio from the previous step.
  • Select Assets/Scripts/Pathfinder.cs.
  • Replace the file contents with this class foundation:
using System.Collections.Generic;

public sealed class Pathfinder
{
    private static readonly int[] DeltaX = { 1, -1, 0, 0 };
    private static readonly int[] DeltaY = { 0, 0, 1, -1 };

    private readonly int width;
    private readonly int height;
    private readonly HashSet<int> blockedCells;
    private readonly HashSet<int> costlyCells;

    public Pathfinder(
        int width,
        int height,
        HashSet<int> blockedCells,
        HashSet<int> costlyCells)
    {
        this.width = width;
        this.height = height;
        this.blockedCells = blockedCells;
        this.costlyCells = costlyCells;
    }
}

What does this code do?

  • The DeltaX array describes horizontal movement.
  • The DeltaY array describes vertical movement.
  • Matching entries from both arrays create right, left, up, and down movement.
  • The constructor stores the grid dimensions and both terrain sets for later searches.
  • Save Assets/Scripts/Pathfinder.cs.
  • Switch back to the Unity Editor.
  • Wait for Unity to finish compiling.

The Console should remain free of red compilation errors. This confirms that the board can create the new Pathfinder object.

Seeing a compilation error?

  • Check that the class name is exactly Pathfinder.
  • Check that both constructor parameters use HashSet<int>.
  • Confirm that the file begins with using System.Collections.Generic;.

Still stuck? Help me fix the Pathfinder class foundation in Unity.

The pathfinder also needs one rule for blocked cells and another rule for movement cost. The cost methods become more important when terrain weighting is enabled later.

  • Switch back to Assets/Scripts/Pathfinder.cs in Visual Studio.
  • Place these methods below the constructor and above the final class brace:
    public bool IsWalkable(int cell)
    {
        return cell >= 0 && cell < width * height && !blockedCells.Contains(cell);
    }

    public int GetMovementCost(int cell)
    {
        return costlyCells.Contains(cell) ? 5 : 1;
    }

    public int GetPathCost(List<int> path, bool useTerrainCosts)
    {
        int total = 0;

        for (int index = 1; index < path.Count; index++)
        {
            total += useTerrainCosts ? GetMovementCost(path[index]) : 1;
        }

        return total;
    }

How do these rules classify cells?

  • The IsWalkable() method rejects indices outside the board.
  • The same method rejects every index stored in blockedCells.
  • The GetMovementCost() method returns 5 for a costly cell.
  • The GetPathCost() method skips the starting cell because entering it does not consume movement.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that the Console remains free of red compilation errors.

Are the collection types underlined?

Confirm that using System.Collections.Generic; remains at the top of the file. That namespace provides the generic list and set types used by these methods.

Need another pair of eyes? Help me diagnose the generic collection errors in Pathfinder.cs.

Score four-direction routes

A* chooses the open cell with the lowest f = g + h score. The accumulated g value measures the known route cost.

The remaining h value uses Manhattan distance. This heuristic matches a grid where movement only travels horizontally or vertically.

  • Switch back to Assets/Scripts/Pathfinder.cs.
  • Add the score-selection method above the final class brace:
    private int GetLowestScore(List<int> open, Dictionary<int, int> gCost, int goal)
    {
        int bestCell = open[0];
        int bestScore = gCost[bestCell] + Heuristic(bestCell, goal);

        for (int index = 1; index < open.Count; index++)
        {
            int candidate = open[index];
            int candidateScore = gCost[candidate] + Heuristic(candidate, goal);

            if (candidateScore < bestScore)
            {
                bestCell = candidate;
                bestScore = candidateScore;
            }
        }

        return bestCell;
    }

How is the next cell selected?

  • The first open cell becomes the initial best candidate.
  • Each remaining candidate receives a score from its known cost plus its estimated distance.
  • A lower score replaces the current candidate.
  • The returned cell becomes the next position A* evaluates.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that the Console remains free of red compilation errors.

Finding the goal only solves half the problem. The pathfinder must follow its predecessor map backward to recover the complete route.

  • Return to Assets/Scripts/Pathfinder.cs.
  • Add the route-reconstruction method above the final class brace:
    private List<int> ReconstructPath(Dictionary<int, int> cameFrom, int current)
    {
        List<int> reversedPath = new List<int> { current };

        while (cameFrom.ContainsKey(current))
        {
            current = cameFrom[current];
            reversedPath.Add(current);
        }

        List<int> path = new List<int>();

        for (int index = reversedPath.Count - 1; index >= 0; index--)
        {
            path.Add(reversedPath[index]);
        }

        return path;
    }

Why reverse the predecessor chain?

The predecessor map starts at the goal and points back toward the start. The second loop reverses that chain so the returned route begins at the player.

  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that Unity finishes compiling without red Console errors.

Seeing an error in ReconstructPath?

  • Check that reversedPath uses List<int>.
  • Check that the reverse loop starts at reversedPath.Count - 1.
  • Check that the loop continues while index >= 0.

Still blocked? Help me fix the route reconstruction method.

The heuristic converts both cell indices back into grid coordinates. Their horizontal and vertical differences produce the Manhattan estimate.

  • Return to Assets/Scripts/Pathfinder.cs.
  • Add the heuristic and coordinate helpers above the final class brace:
    private int Heuristic(int from, int to)
    {
        int fromX = from % width;
        int fromY = from / width;
        int toX = to % width;
        int toY = to / width;

        return Absolute(fromX - toX) + Absolute(fromY - toY);
    }

    private int ToCell(int x, int y)
    {
        return y * width + x;
    }

    private static int Absolute(int value)
    {
        return value < 0 ? -value : value;
    }

How does the heuristic read the grid?

  • The remainder operator recovers the horizontal coordinate from a cell index.
  • Integer division recovers the vertical coordinate.
  • The Absolute() helper turns both coordinate differences into positive distances.
  • Adding those differences estimates the number of orthogonal steps to the goal.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that Unity finishes compiling without red Console errors.

The main search method brings the collections and scoring helpers together. Its first layer creates the search state and processes the current best cell.

  • Return to Assets/Scripts/Pathfinder.cs.
  • Add the initial FindPath() method below the constructor:
    public List<int> FindPath(int start, int goal, bool useTerrainCosts)
    {
        List<int> open = new List<int> { start };
        HashSet<int> closed = new HashSet<int>();
        Dictionary<int, int> cameFrom = new Dictionary<int, int>();
        Dictionary<int, int> gCost = new Dictionary<int, int>
        {
            { start, 0 }
        };

        while (open.Count > 0)
        {
            int current = GetLowestScore(open, gCost, goal);

            if (current == goal)
            {
                return ReconstructPath(cameFrom, current);
            }

            open.Remove(current);
            closed.Add(current);
        }

        return new List<int>();
    }

What does the search loop control?

  • The starting cell enters open with a known cost of 0.
  • The loop selects the lowest-scoring candidate during each pass.
  • Reaching the goal triggers route reconstruction.
  • A search that exhausts every candidate returns an empty route.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that the Console remains free of red compilation errors.

The current cell can produce up to four neighboring coordinates. Bounds checks prevent those coordinates from wrapping across an edge of the board.

  • Return to the FindPath() method in Assets/Scripts/Pathfinder.cs.
  • Find the line closed.Add(current);.
  • Insert this neighbor loop directly below that line:
            int currentX = current % width;
            int currentY = current / width;

            for (int direction = 0; direction < DeltaX.Length; direction++)
            {
                int nextX = currentX + DeltaX[direction];
                int nextY = currentY + DeltaY[direction];

                if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height)
                {
                    continue;
                }

                int neighbor = ToCell(nextX, nextY);

                if (!IsWalkable(neighbor) || closed.Contains(neighbor))
                {
                    continue;
                }
            }

How are valid neighbors found?

  • The current index becomes an x coordinate and a y coordinate.
  • Each direction pair produces one orthogonal neighbor.
  • The bounds condition rejects coordinates outside the board.
  • The walkability condition rejects walls and previously closed cells.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Confirm that Unity compiles the neighbor loop without red Console errors.

A valid neighbor still needs a tentative route cost. A cheaper route updates its predecessor and returns that cell to the open list.

  • Return to the neighbor loop in FindPath().
  • Find the closing brace immediately after the closed.Contains(neighbor) check.
  • Insert this cost update before the closing brace of the direction loop:
                int stepCost = useTerrainCosts ? GetMovementCost(neighbor) : 1;
                int tentativeCost = gCost[current] + stepCost;
                int knownCost;

                if (!gCost.TryGetValue(neighbor, out knownCost) || tentativeCost < knownCost)
                {
                    cameFrom[neighbor] = current;
                    gCost[neighbor] = tentativeCost;

                    if (!open.Contains(neighbor))
                    {
                        open.Add(neighbor);
                    }
                }

When does A* update a route?

  • The stepCost value stays at 1 while terrain costs are disabled.
  • The tentative cost adds that step to the current cell's accumulated cost.
  • An undiscovered neighbor always receives its first cost and predecessor.
  • A discovered neighbor changes only when the new route is cheaper.
  • Save Assets/Scripts/Pathfinder.cs.
  • Return to the Unity Editor.
  • Press the Play button.

You should see the existing board and blue unit. The Console should remain free of red errors.

  • Stop Play Mode with the Play button.

Does Unity report a brace error?

  • Check that the cost update sits inside the direction loop.
  • Check that the direction loop sits inside the while loop.
  • Check that the final empty-list return remains after the while loop.

Need help locating the mismatch? Help me inspect the braces in FindPath().

✔️ Awesome, I've got everything!

Your Pathfinder now contains the complete A* search infrastructure. Confirm that Assets/Scripts/Pathfinder.cs is saved.

ⓧ I'd like to double check the full code

Compare your complete Assets/Scripts/Pathfinder.cs with this reference.

using System.Collections.Generic;

public sealed class Pathfinder
{
    private static readonly int[] DeltaX = { 1, -1, 0, 0 };
    private static readonly int[] DeltaY = { 0, 0, 1, -1 };

    private readonly int width;
    private readonly int height;
    private readonly HashSet<int> blockedCells;
    private readonly HashSet<int> costlyCells;

    public Pathfinder(
        int width,
        int height,
        HashSet<int> blockedCells,
        HashSet<int> costlyCells)
    {
        this.width = width;
        this.height = height;
        this.blockedCells = blockedCells;
        this.costlyCells = costlyCells;
    }

    public List<int> FindPath(int start, int goal, bool useTerrainCosts)
    {
        List<int> open = new List<int> { start };
        HashSet<int> closed = new HashSet<int>();
        Dictionary<int, int> cameFrom = new Dictionary<int, int>();
        Dictionary<int, int> gCost = new Dictionary<int, int>
        {
            { start, 0 }
        };

        while (open.Count > 0)
        {
            int current = GetLowestScore(open, gCost, goal);

            if (current == goal)
            {
                return ReconstructPath(cameFrom, current);
            }

            open.Remove(current);
            closed.Add(current);

            int currentX = current % width;
            int currentY = current / width;

            for (int direction = 0; direction < DeltaX.Length; direction++)
            {
                int nextX = currentX + DeltaX[direction];
                int nextY = currentY + DeltaY[direction];

                if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height)
                {
                    continue;
                }

                int neighbor = ToCell(nextX, nextY);

                if (!IsWalkable(neighbor) || closed.Contains(neighbor))
                {
                    continue;
                }

                int stepCost = useTerrainCosts ? GetMovementCost(neighbor) : 1;
                int tentativeCost = gCost[current] + stepCost;
                int knownCost;

                if (!gCost.TryGetValue(neighbor, out knownCost) || tentativeCost < knownCost)
                {
                    cameFrom[neighbor] = current;
                    gCost[neighbor] = tentativeCost;

                    if (!open.Contains(neighbor))
                    {
                        open.Add(neighbor);
                    }
                }
            }
        }

        return new List<int>();
    }

    public bool IsWalkable(int cell)
    {
        return cell >= 0 && cell < width * height && !blockedCells.Contains(cell);
    }

    public int GetMovementCost(int cell)
    {
        return costlyCells.Contains(cell) ? 5 : 1;
    }

    public int GetPathCost(List<int> path, bool useTerrainCosts)
    {
        int total = 0;

        for (int index = 1; index < path.Count; index++)
        {
            total += useTerrainCosts ? GetMovementCost(path[index]) : 1;
        }

        return total;
    }

    private int GetLowestScore(List<int> open, Dictionary<int, int> gCost, int goal)
    {
        int bestCell = open[0];
        int bestScore = gCost[bestCell] + Heuristic(bestCell, goal);

        for (int index = 1; index < open.Count; index++)
        {
            int candidate = open[index];
            int candidateScore = gCost[candidate] + Heuristic(candidate, goal);

            if (candidateScore < bestScore)
            {
                bestCell = candidate;
                bestScore = candidateScore;
            }
        }

        return bestCell;
    }

    private List<int> ReconstructPath(Dictionary<int, int> cameFrom, int current)
    {
        List<int> reversedPath = new List<int> { current };

        while (cameFrom.ContainsKey(current))
        {
            current = cameFrom[current];
            reversedPath.Add(current);
        }

        List<int> path = new List<int>();

        for (int index = reversedPath.Count - 1; index >= 0; index--)
        {
            path.Add(reversedPath[index]);
        }

        return path;
    }

    private int Heuristic(int from, int to)
    {
        int fromX = from % width;
        int fromY = from / width;
        int toX = to % width;
        int toY = to / width;

        return Absolute(fromX - toX) + Absolute(fromY - toY);
    }

    private int ToCell(int x, int y)
    {
        return y * width + x;
    }

    private static int Absolute(int value)
    {
        return value < 0 ? -value : value;
    }
}
Turn clicks into route previews

The pathfinder now returns cell indices. The board needs raycasting to connect a mouse position with the cube that received the click.

The clicked cube maps back to a cell through cellsByTile. The returned path can then recolor its tiles without changing the underlying terrain.

  • Switch to Assets/Scripts/GridStrategyGame.cs in Visual Studio.
  • Find the GetBaseColor() method.
  • Add this color-refresh method directly above it:
    private void RefreshTileColors()
    {
        foreach (KeyValuePair<int, GameObject> tile in tiles)
        {
            SetTileColor(tile.Key, GetBaseColor(tile.Key));
        }

        int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
        Color previewColor = cost <= MovementBudget
            ? new Color(1f, 0.85f, 0.1f)
            : new Color(1f, 0.2f, 0.2f);

        for (int index = 1; index < previewPath.Count; index++)
        {
            SetTileColor(previewPath[index], previewColor);
        }

        SetTileColor(playerCell, new Color(0.1f, 0.65f, 1f));
    }

How does the preview preserve terrain colors?

  • The first loop restores every tile's base terrain color.
  • The path cost selects yellow for an affordable route.
  • The preview loop skips the player's starting cell.
  • The final line restores the blue marker beneath the player.
  • Save Assets/Scripts/GridStrategyGame.cs.
  • Return to the Unity Editor.
  • Confirm that Unity compiles without red Console errors.

A destination click must reject walls before requesting a route. A valid destination replaces the current preview with the list returned by FindPath().

  • Return to Assets/Scripts/GridStrategyGame.cs.
  • Add this click handler directly above RefreshTileColors().
    private void HandleCellClick(int clickedCell)
    {
        if (!pathfinder.IsWalkable(clickedCell))
        {
            previewPath.Clear();
            status = "Ese muro no es transitable.";
            RefreshTileColors();
            return;
        }

        previewPath = pathfinder.FindPath(playerCell, clickedCell, useTerrainCosts);

        if (previewPath.Count == 0)
        {
            status = "No existe una ruta hasta esa casilla.";
        }
        else
        {
            int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
            status = "Ruta: " + (previewPath.Count - 1) + " pasos, coste " + cost + ".";
        }

        RefreshTileColors();
    }

How is a destination handled?

  • A wall clears the previous preview and ends the click handler.
  • A walkable destination is passed to FindPath() with the player's current cell.
  • An empty result records that no route exists.
  • A successful result records its step count and total cost.
  • Save Assets/Scripts/GridStrategyGame.cs.
  • Return to the Unity Editor.
  • Confirm that the Console remains free of red compilation errors.

The final connection listens for the left mouse button during Update(). A camera ray identifies the collider beneath the pointer.

  • Return to Assets/Scripts/GridStrategyGame.cs.
  • Find the closing brace of the Start() method.
  • Add this Update() method directly below it:
    private void Update()
    {
        if (moving || !Input.GetMouseButtonDown(0))
        {
            return;
        }

        Ray ray = gameCamera.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        if (!Physics.Raycast(ray, out hit))
        {
            return;
        }

        int clickedCell;

        if (!cellsByTile.TryGetValue(hit.collider.gameObject, out clickedCell))
        {
            return;
        }

        HandleCellClick(clickedCell);
    }

How does a click become a cell index?

  • The input check runs only on the frame when the left mouse button is pressed.
  • The camera converts the pointer position into a world-space ray.
  • The physics query reports the collider hit by that ray.
  • The cellsByTile lookup converts the hit cube into its logical cell index.
  • Save Assets/Scripts/GridStrategyGame.cs.
  • Return to the Unity Editor.
  • Wait for Unity to finish compiling.

Before you press Play, which opening do you think A* will use when every walkable tile costs the same?

  • Select GameManager in the Hierarchy.
  • Clear the Use Terrain Costs checkbox in the Inspector.
  • Press the Play button.
  • Click the tile near (7, 2).

You should see a yellow route bend around the gray barrier. The route crosses the brown mud because every walkable cell currently costs 1.

That is the key search working. Your unit now receives a valid route that never passes through a wall.

No yellow route after clicking?

  • Confirm that you clicked a green or brown tile instead of the gray wall.
  • Check that GameManager still has the GridStrategyGame component.
  • Check that Update() passes hit.collider.gameObject into the cellsByTile lookup.

Still seeing the base tile colors? Help me debug the Unity raycast and route preview.

✔️ Awesome, I've got everything!

Your saved scene now runs with mouse raycasts and yellow route previews. Confirm that Assets/Scripts/GridStrategyGame.cs is saved.

ⓧ I'd like to double check the full code

Compare your cumulative Assets/Scripts/GridStrategyGame.cs with this reference.

using System.Collections.Generic;
using UnityEngine;

public class GridStrategyGame : MonoBehaviour
{
    private const int Width = 10;
    private const int Height = 8;
    private const int MovementBudget = 18;

    private readonly Dictionary<int, GameObject> tiles = new Dictionary<int, GameObject>();
    private readonly Dictionary<GameObject, int> cellsByTile = new Dictionary<GameObject, int>();
    private readonly HashSet<int> walls = new HashSet<int>();
    private readonly HashSet<int> mud = new HashSet<int>();

    public bool useTerrainCosts = true;

    private Pathfinder pathfinder;
    private Camera gameCamera;
    private GameObject playerUnit;
    private List<int> previewPath = new List<int>();
    private int playerCell;
    private bool moving;
    private string status = "Haz clic en una casilla para calcular una ruta.";

    private void Start()
    {
        ConfigureMap();
        pathfinder = new Pathfinder(Width, Height, walls, mud);
        ConfigureCamera();
        BuildBoard();

        playerCell = ToCell(1, 1);
        playerUnit = CreateUnit(new Color(0.15f, 0.45f, 1f), playerCell, PrimitiveType.Sphere);
        RefreshTileColors();
    }

    private void Update()
    {
        if (moving || !Input.GetMouseButtonDown(0))
        {
            return;
        }

        Ray ray = gameCamera.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        if (!Physics.Raycast(ray, out hit))
        {
            return;
        }

        int clickedCell;

        if (!cellsByTile.TryGetValue(hit.collider.gameObject, out clickedCell))
        {
            return;
        }

        HandleCellClick(clickedCell);
    }

    private void ConfigureMap()
    {
        walls.Add(ToCell(4, 0));
        walls.Add(ToCell(4, 1));
        walls.Add(ToCell(4, 2));
        walls.Add(ToCell(4, 4));
        walls.Add(ToCell(4, 5));
        walls.Add(ToCell(4, 6));

        mud.Add(ToCell(2, 3));
        mud.Add(ToCell(3, 3));
        mud.Add(ToCell(4, 3));
        mud.Add(ToCell(5, 3));
        mud.Add(ToCell(6, 3));
    }

    private void ConfigureCamera()
    {
        gameCamera = Camera.main;

        if (gameCamera == null)
        {
            GameObject cameraObject = new GameObject("Runtime Camera", typeof(Camera));
            gameCamera = cameraObject.GetComponent<Camera>();
        }

        gameCamera.transform.position = new Vector3(
            (Width - 1) * 0.5f,
            10f,
            (Height - 1) * 0.5f);
        gameCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
        gameCamera.orthographic = true;
        gameCamera.orthographicSize = 5.2f;
    }

    private void BuildBoard()
    {
        for (int y = 0; y < Height; y++)
        {
            for (int x = 0; x < Width; x++)
            {
                int cell = ToCell(x, y);
                GameObject tile = GameObject.CreatePrimitive(PrimitiveType.Cube);
                tile.name = "Tile " + x + "," + y;

                bool isWall = walls.Contains(cell);
                tile.transform.position = new Vector3(x, isWall ? 0.3f : 0f, y);
                tile.transform.localScale = new Vector3(0.92f, isWall ? 0.7f : 0.12f, 0.92f);

                tiles.Add(cell, tile);
                cellsByTile.Add(tile, cell);
                SetTileColor(cell, GetBaseColor(cell));
            }
        }
    }

    private void HandleCellClick(int clickedCell)
    {
        if (!pathfinder.IsWalkable(clickedCell))
        {
            previewPath.Clear();
            status = "Ese muro no es transitable.";
            RefreshTileColors();
            return;
        }

        previewPath = pathfinder.FindPath(playerCell, clickedCell, useTerrainCosts);

        if (previewPath.Count == 0)
        {
            status = "No existe una ruta hasta esa casilla.";
        }
        else
        {
            int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
            status = "Ruta: " + (previewPath.Count - 1) + " pasos, coste " + cost + ".";
        }

        RefreshTileColors();
    }

    private void RefreshTileColors()
    {
        foreach (KeyValuePair<int, GameObject> tile in tiles)
        {
            SetTileColor(tile.Key, GetBaseColor(tile.Key));
        }

        int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
        Color previewColor = cost <= MovementBudget
            ? new Color(1f, 0.85f, 0.1f)
            : new Color(1f, 0.2f, 0.2f);

        for (int index = 1; index < previewPath.Count; index++)
        {
            SetTileColor(previewPath[index], previewColor);
        }

        SetTileColor(playerCell, new Color(0.1f, 0.65f, 1f));
    }

    private Color GetBaseColor(int cell)
    {
        if (walls.Contains(cell))
        {
            return new Color(0.2f, 0.22f, 0.25f);
        }

        if (mud.Contains(cell))
        {
            return new Color(0.45f, 0.25f, 0.08f);
        }

        return new Color(0.2f, 0.6f, 0.25f);
    }

    private void SetTileColor(int cell, Color color)
    {
        tiles[cell].GetComponent<Renderer>().material.color = color;
    }

    private int ToCell(int x, int y)
    {
        return y * Width + x;
    }

    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }
}

Your board can now turn a mouse click into a valid route around blocked cells. Next, you will make A* compare the real cost of green terrain with the higher cost of mud.

Apply Terrain Costs

La ruta actual ya demuestra que A* puede rodear los muros. Sin embargo, atraviesa el barro porque cada casilla cuenta como un único paso.

En este paso, conectarás los costes del terreno con la decisión de la ruta. También convertirás el presupuesto en una señal visual dentro de Unity.

In this step, get ready to:
  • Reproducir la ruta que atraviesa el barro.
  • Activar los costes ponderados de A*.
  • Colorear las rutas según el presupuesto disponible.
Reproduce la ruta tácticamente cara

La ruta actual minimiza la cantidad de pasos. El barro tiene el mismo peso que una casilla verde mientras los costes de terreno están desactivados.

Antes de probarlo, ¿crees que la ruta más corta atravesará la franja marrón?

  • Selecciona GameManager en el panel de jerarquía.
  • Desactiva Use Terrain Costs en el Inspector.
  • Pulsa Play en la barra superior.
  • Haz clic en la casilla situada aproximadamente en (7, 2).

Verás una ruta amarilla que rodea la barrera. La ruta atraviesa la franja de barro porque todas las casillas todavía aportan el mismo coste.

Por qué esta ruta se queda corta

La ruta es transitable. Su decisión resulta cara cuando cada casilla marrón representa más esfuerzo que una casilla verde.

Esta diferencia visible es el problema que resuelve la puntuación ponderada.

  • Pulsa Play de nuevo para salir de Play Mode.
Activa la puntuación ponderada

El pathfinder ya contiene una función que traduce cada casilla transitable a un coste. Los muros siguen excluidos mediante IsWalkable().

  • Abre Assets/Scripts/Pathfinder.cs desde el panel del proyecto.
  • Localiza GetMovementCost() y compáralo con esta referencia:
public int GetMovementCost(int cell)
    {
        return costlyCells.Contains(cell) ? 5 : 1;
    }

Cómo se representa el terreno

  • Una casilla incluida en costlyCells cuesta 5 puntos.
  • Cualquier otra casilla transitable cuesta 1 punto.
  • Una casilla incluida en blockedCells queda fuera de la búsqueda.

El coste de entrada del vecino se suma al coste acumulado g. A* usa ese total para comparar el esfuerzo real de las rutas.

  • Localiza el cálculo de stepCost dentro de FindPath().
  • Confirma que el cálculo coincide con estas líneas:
int stepCost = useTerrainCosts ? GetMovementCost(neighbor) : 1;
                int tentativeCost = gCost[current] + stepCost;

Por qué Manhattan sigue funcionando

La distancia Manhattan estima los movimientos horizontales más los verticales. Su estimación utiliza el coste mínimo posible de un paso.

El coste mínimo sigue siendo 1. Por eso h permanece sin escalar dentro de f = g + h.

  • Vuelve al editor de Unity.
  • Selecciona GameManager en el panel de jerarquía.
  • Activa Use Terrain Costs en el Inspector.

Antes de volver a probarlo, ¿esperas que A* mantenga el corredor embarrado o que utilice la abertura superior?

  • Pulsa Play.
  • Haz clic en la casilla situada aproximadamente en (7, 2).

Verás que la ruta utiliza la abertura superior. El HUD mostrará un coste de 17 porque esta ruta evita el corredor embarrado de coste 21.

Buen trabajo. A* ya compara el esfuerzo completo de cada alternativa antes de elegir una ruta.

  • Pulsa Play de nuevo para salir de Play Mode.
Colorea el presupuesto de la ruta

El coste calculado también puede comunicar si el jugador dispone de suficientes puntos de movimiento. Una ruta asequible será amarilla. Una ruta por encima de MovementBudget será roja.

  • Vuelve a Assets/Scripts/GridStrategyGame.cs en tu editor de código.
  • Localiza el método RefreshTileColors().
  • Sustituye el método completo por este código:
private void RefreshTileColors()
    {
        foreach (KeyValuePair<int, GameObject> tile in tiles)
        {
            SetTileColor(tile.Key, GetBaseColor(tile.Key));
        }

        int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
        Color previewColor = cost <= MovementBudget
            ? new Color(1f, 0.85f, 0.1f)
            : new Color(1f, 0.2f, 0.2f);

        for (int index = 1; index < previewPath.Count; index++)
        {
            SetTileColor(previewPath[index], previewColor);
        }

        SetTileColor(playerCell, new Color(0.1f, 0.65f, 1f));
    }

Qué hace este método

  • El primer bucle restaura el color base de cada casilla.
  • GetPathCost() calcula el coste de la ruta con el modo actual.
  • previewColor se vuelve amarillo cuando el coste cabe dentro del presupuesto.
  • previewColor se vuelve rojo cuando el coste supera el presupuesto.
  • El último cambio conserva la casilla del jugador en azul.
  • Guarda Assets/Scripts/GridStrategyGame.cs con Ctrl+S.
  • Vuelve al editor de Unity.
  • Espera a que Unity termine de compilar.
  • Confirma que Use Terrain Costs sigue activado en GameManager.

Antes de ejecutar la escena, ¿qué color esperas para una ruta de coste 17?

  • Pulsa Play.
  • Haz clic en la casilla situada aproximadamente en (7, 2).

Verás una ruta amarilla por la abertura superior. El HUD mostrará un coste de 17.

  • Haz clic en una casilla transitable más lejana hasta que el HUD muestre un coste superior a 18.

Verás que la nueva ruta se vuelve roja. Ese color indica que el coste supera MovementBudget.

  • Haz clic otra vez en la casilla situada aproximadamente en (7, 2).
  • Pulsa Play para salir de Play Mode.

La vista final vuelve a mostrar la ruta amarilla de coste 17. El barro queda fuera de la alternativa elegida.

¿La ruta sigue cruzando el barro?

  • Confirma que Use Terrain Costs está activado en el componente de GameManager antes de entrar en Play Mode.
  • Comprueba que stepCost llama a GetMovementCost(neighbor) cuando el campo está activado.
  • Compara RefreshTileColors() con el bloque anterior si todas las rutas conservan el mismo color.

Si el comportamiento sigue siendo distinto, ayúdame a revisar el cálculo de costes de mi ruta A* en Unity.

✔️ Awesome, I've got everything!

Perfecto. Comprueba que ambos scripts están guardados. Confirma también que Use Terrain Costs permanece activado en GameManager.

ⓧ I'd like to double check the full code

Compara estos archivos con tu código acumulado. Assets/Scripts/Pathfinder.cs contiene el pathfinder ponderado completo.

using System.Collections.Generic;

public sealed class Pathfinder
{
    private static readonly int[] DeltaX = { 1, -1, 0, 0 };
    private static readonly int[] DeltaY = { 0, 0, 1, -1 };

    private readonly int width;
    private readonly int height;
    private readonly HashSet<int> blockedCells;
    private readonly HashSet<int> costlyCells;

    public Pathfinder(
        int width,
        int height,
        HashSet<int> blockedCells,
        HashSet<int> costlyCells)
    {
        this.width = width;
        this.height = height;
        this.blockedCells = blockedCells;
        this.costlyCells = costlyCells;
    }

    public List<int> FindPath(int start, int goal, bool useTerrainCosts)
    {
        List<int> open = new List<int> { start };
        HashSet<int> closed = new HashSet<int>();
        Dictionary<int, int> cameFrom = new Dictionary<int, int>();
        Dictionary<int, int> gCost = new Dictionary<int, int>
        {
            { start, 0 }
        };

        while (open.Count > 0)
        {
            int current = GetLowestScore(open, gCost, goal);

            if (current == goal)
            {
                return ReconstructPath(cameFrom, current);
            }

            open.Remove(current);
            closed.Add(current);

            int currentX = current % width;
            int currentY = current / width;

            for (int direction = 0; direction < DeltaX.Length; direction++)
            {
                int nextX = currentX + DeltaX[direction];
                int nextY = currentY + DeltaY[direction];

                if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height)
                {
                    continue;
                }

                int neighbor = ToCell(nextX, nextY);

                if (!IsWalkable(neighbor) || closed.Contains(neighbor))
                {
                    continue;
                }

                int stepCost = useTerrainCosts ? GetMovementCost(neighbor) : 1;
                int tentativeCost = gCost[current] + stepCost;
                int knownCost;

                if (!gCost.TryGetValue(neighbor, out knownCost) || tentativeCost < knownCost)
                {
                    cameFrom[neighbor] = current;
                    gCost[neighbor] = tentativeCost;

                    if (!open.Contains(neighbor))
                    {
                        open.Add(neighbor);
                    }
                }
            }
        }

        return new List<int>();
    }

    public bool IsWalkable(int cell)
    {
        return cell >= 0 && cell < width * height && !blockedCells.Contains(cell);
    }

    public int GetMovementCost(int cell)
    {
        return costlyCells.Contains(cell) ? 5 : 1;
    }

    public int GetPathCost(List<int> path, bool useTerrainCosts)
    {
        int total = 0;

        for (int index = 1; index < path.Count; index++)
        {
            total += useTerrainCosts ? GetMovementCost(path[index]) : 1;
        }

        return total;
    }

    private int GetLowestScore(List<int> open, Dictionary<int, int> gCost, int goal)
    {
        int bestCell = open[0];
        int bestScore = gCost[bestCell] + Heuristic(bestCell, goal);

        for (int index = 1; index < open.Count; index++)
        {
            int candidate = open[index];
            int candidateScore = gCost[candidate] + Heuristic(candidate, goal);

            if (candidateScore < bestScore)
            {
                bestCell = candidate;
                bestScore = candidateScore;
            }
        }

        return bestCell;
    }

    private List<int> ReconstructPath(Dictionary<int, int> cameFrom, int current)
    {
        List<int> reversedPath = new List<int> { current };

        while (cameFrom.ContainsKey(current))
        {
            current = cameFrom[current];
            reversedPath.Add(current);
        }

        List<int> path = new List<int>();

        for (int index = reversedPath.Count - 1; index >= 0; index--)
        {
            path.Add(reversedPath[index]);
        }

        return path;
    }

    private int Heuristic(int from, int to)
    {
        int fromX = from % width;
        int fromY = from / width;
        int toX = to % width;
        int toY = to / width;

        return Absolute(fromX - toX) + Absolute(fromY - toY);
    }

    private int ToCell(int x, int y)
    {
        return y * width + x;
    }

    private static int Absolute(int value)
    {
        return value < 0 ? -value : value;
    }
}

Este es el contenido acumulado de Assets/Scripts/GridStrategyGame.cs al terminar este paso.

using System.Collections.Generic;
using UnityEngine;

public class GridStrategyGame : MonoBehaviour
{
    private const int Width = 10;
    private const int Height = 8;
    private const int MovementBudget = 18;

    private readonly Dictionary<int, GameObject> tiles = new Dictionary<int, GameObject>();
    private readonly Dictionary<GameObject, int> cellsByTile = new Dictionary<GameObject, int>();
    private readonly HashSet<int> walls = new HashSet<int>();
    private readonly HashSet<int> mud = new HashSet<int>();

    public bool useTerrainCosts = true;

    private Pathfinder pathfinder;
    private Camera gameCamera;
    private GameObject playerUnit;
    private List<int> previewPath = new List<int>();
    private int playerCell;
    private bool moving;
    private string status = "Haz clic en una casilla para calcular una ruta.";

    private void Start()
    {
        ConfigureMap();
        pathfinder = new Pathfinder(Width, Height, walls, mud);
        ConfigureCamera();
        BuildBoard();

        playerCell = ToCell(1, 1);
        playerUnit = CreateUnit(new Color(0.15f, 0.45f, 1f), playerCell, PrimitiveType.Sphere);
        RefreshTileColors();
    }

    private void Update()
    {
        if (moving || !Input.GetMouseButtonDown(0))
        {
            return;
        }

        Ray ray = gameCamera.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        if (!Physics.Raycast(ray, out hit))
        {
            return;
        }

        int clickedCell;

        if (!cellsByTile.TryGetValue(hit.collider.gameObject, out clickedCell))
        {
            return;
        }

        HandleCellClick(clickedCell);
    }

    private void ConfigureMap()
    {
        walls.Add(ToCell(4, 0));
        walls.Add(ToCell(4, 1));
        walls.Add(ToCell(4, 2));
        walls.Add(ToCell(4, 4));
        walls.Add(ToCell(4, 5));
        walls.Add(ToCell(4, 6));

        mud.Add(ToCell(2, 3));
        mud.Add(ToCell(3, 3));
        mud.Add(ToCell(4, 3));
        mud.Add(ToCell(5, 3));
        mud.Add(ToCell(6, 3));
    }

    private void ConfigureCamera()
    {
        gameCamera = Camera.main;

        if (gameCamera == null)
        {
            GameObject cameraObject = new GameObject("Runtime Camera", typeof(Camera));
            gameCamera = cameraObject.GetComponent<Camera>();
        }

        gameCamera.transform.position = new Vector3(
            (Width - 1) * 0.5f,
            10f,
            (Height - 1) * 0.5f);
        gameCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
        gameCamera.orthographic = true;
        gameCamera.orthographicSize = 5.2f;
    }

    private void BuildBoard()
    {
        for (int y = 0; y < Height; y++)
        {
            for (int x = 0; x < Width; x++)
            {
                int cell = ToCell(x, y);
                GameObject tile = GameObject.CreatePrimitive(PrimitiveType.Cube);
                tile.name = "Tile " + x + "," + y;

                bool isWall = walls.Contains(cell);
                tile.transform.position = new Vector3(x, isWall ? 0.3f : 0f, y);
                tile.transform.localScale = new Vector3(0.92f, isWall ? 0.7f : 0.12f, 0.92f);

                tiles.Add(cell, tile);
                cellsByTile.Add(tile, cell);
                SetTileColor(cell, GetBaseColor(cell));
            }
        }
    }

    private void HandleCellClick(int clickedCell)
    {
        if (!pathfinder.IsWalkable(clickedCell))
        {
            previewPath.Clear();
            status = "Ese muro no es transitable.";
            RefreshTileColors();
            return;
        }

        previewPath = pathfinder.FindPath(playerCell, clickedCell, useTerrainCosts);

        if (previewPath.Count == 0)
        {
            status = "No existe una ruta hasta esa casilla.";
        }
        else
        {
            int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
            status = "Ruta: " + (previewPath.Count - 1) + " pasos, coste " + cost + ".";
        }

        RefreshTileColors();
    }

    private void RefreshTileColors()
    {
        foreach (KeyValuePair<int, GameObject> tile in tiles)
        {
            SetTileColor(tile.Key, GetBaseColor(tile.Key));
        }

        int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
        Color previewColor = cost <= MovementBudget
            ? new Color(1f, 0.85f, 0.1f)
            : new Color(1f, 0.2f, 0.2f);

        for (int index = 1; index < previewPath.Count; index++)
        {
            SetTileColor(previewPath[index], previewColor);
        }

        SetTileColor(playerCell, new Color(0.1f, 0.65f, 1f));
    }

    private Color GetBaseColor(int cell)
    {
        if (walls.Contains(cell))
        {
            return new Color(0.2f, 0.22f, 0.25f);
        }

        if (mud.Contains(cell))
        {
            return new Color(0.45f, 0.25f, 0.08f);
        }

        return new Color(0.2f, 0.6f, 0.25f);
    }

    private void SetTileColor(int cell, Color color)
    {
        tiles[cell].GetComponent<Renderer>().material.color = color;
    }

    private int ToCell(int x, int y)
    {
        return y * Width + x;
    }

    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }

    private void OnGUI()
    {
        GUI.Label(new Rect(20f, 20f, 900f, 25f),
            "Clic: previsualizar. Segundo clic en el mismo destino: mover.");
        GUI.Label(new Rect(20f, 45f, 900f, 25f),
            "Presupuesto: " + MovementBudget + " | Costes de terreno: " + useTerrainCosts);
        GUI.Label(new Rect(20f, 70f, 900f, 25f), status);
    }
}

El pathfinder ya elige rutas según su coste táctico. En el siguiente paso, convertirás esa vista previa en un movimiento confirmado casilla por casilla.

Confirm and Move the Unit

Your weighted A* route now avoids walls. It also chooses lower-cost terrain. However, the player can only preview that decision.

This step turns the preview into a playable mechanic in Unity. A second click confirms the destination. The movement budget rejects expensive routes. The blue sphere then travels through each cell.

In this step, get ready to:
  • Animate the blue unit along a confirmed route.
  • Allow movement only when the route costs at most 18 points.
  • Finish the movement API and HUD.
Animate movement across the route

A coroutine spreads movement across multiple frames. Vector3.MoveTowards prevents the sphere from overshooting each target. Time.deltaTime keeps its speed consistent.

  • Double-click Assets/Scripts/GridStrategyGame.cs in Unity to return to the script in Visual Studio.
  • Add the coroutine namespace at the top of GridStrategyGame.cs:
using System.Collections;

Why is this namespace required?

The movement method returns IEnumerator. That interface comes from System.Collections and lets Unity pause the method between frames.

  • Find the closing brace of HandleCellClick.
  • Add the movement coroutine directly below it:
    private IEnumerator MoveUnit(List<int> path)
    {
        moving = true;
        status = "Unidad en movimiento.";

        for (int index = 1; index < path.Count; index++)
        {
            Vector3 target = CellToWorld(path[index]);

            while ((playerUnit.transform.position - target).sqrMagnitude > 0.0001f)
            {
                playerUnit.transform.position = Vector3.MoveTowards(
                    playerUnit.transform.position,
                    target,
                    4f * Time.deltaTime);
                yield return null;
            }

            playerUnit.transform.position = target;
            playerCell = path[index];
            RefreshTileColors();
        }

        previewPath.Clear();
        moving = false;
        status = "Movimiento completado. Elige otro destino.";
        RefreshTileColors();
    }

How does the movement coroutine work?

  • The loop starts at index 1 because index 0 is the unit's current cell.
  • Each path index becomes a world-space target through CellToWorld.
  • The inner loop moves the sphere closer on every frame.
  • The final lines clear the preview after the route is complete.
  • Save GridStrategyGame.cs by pressing Ctrl+S.
  • Switch back to Unity.
  • Select the Console tab at the bottom of the editor.

You should see no red compilation errors. The movement routine is now part of the game script.

Seeing a compilation error?

  • Check that using System.Collections; appears with the other namespace imports.
  • Check that MoveUnit sits inside the GridStrategyGame class.
  • Check that MoveUnit sits outside HandleCellClick.

Still stuck? Help me fix the C# compilation error in my MoveUnit coroutine.

Confirm routes within the movement budget

The current preview already stores its destination in previewPath. Clicking that destination again can therefore act as confirmation. The route starts only when its cost fits within MovementBudget.

  • Find this line inside HandleCellClick: previewPath = pathfinder.FindPath(playerCell, clickedCell, useTerrainCosts);
  • Insert the confirmation block directly above that line:
        if (previewPath.Count > 0 && previewPath[previewPath.Count - 1] == clickedCell)
        {
            int confirmedCost = pathfinder.GetPathCost(previewPath, useTerrainCosts);

            if (confirmedCost > MovementBudget)
            {
                status = "La ruta supera el presupuesto de " + MovementBudget + ".";
                return;
            }

            StartCoroutine(MoveUnit(new List<int>(previewPath)));
            return;
        }

What does the confirmation block do?

  • The condition checks whether the clicked cell matches the final cell in the current preview.
  • GetPathCost calculates the confirmed route's total movement cost.
  • A cost above MovementBudget stops the confirmation.
  • An affordable route is copied before the coroutine starts.
  • Save GridStrategyGame.cs.

Before you test it, predict what the blue sphere should do after you click the same affordable destination twice.

  • Switch back to Unity.
  • Press Play.
  • Click an affordable green destination once.
  • Click the same destination a second time.

You should see the blue sphere move through each cell in the preview. The board returns to its base colors when movement finishes.

Unit stays still?

  • Confirm that both clicks land on the same destination tile.
  • Check the HUD for a message that says the route exceeds the budget.
  • Check that StartCoroutine(MoveUnit(new List<int>(previewPath))) appears inside the confirmation block.

Need help? Help me debug why the blue unit does not move after the second click.

  • Exit Play Mode by pressing Play again.
Finish the movement API and HUD

The completed movement system exposes a small public API. Other gameplay components can read the player's cell. They can also check whether movement is active.

  • Find the status field near the top of GridStrategyGame.cs.
  • Add these public state properties directly below it:
    public int PlayerCell
    {
        get { return playerCell; }
    }

    public bool IsMoving
    {
        get { return moving; }
    }

    public int CellCount
    {
        get { return Width * Height; }
    }

What do these properties expose?

  • PlayerCell reports the unit's current grid index.
  • IsMoving reports whether the movement coroutine is active.
  • CellCount reports the total number of cells without exposing the private dimensions.
  • Locate the helper section near the bottom of GridStrategyGame.cs.
  • Replace the existing coordinate and unit helpers with this public group:
    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

    public List<int> FindPathFor(int start, int goal)
    {
        return pathfinder.FindPath(start, goal, useTerrainCosts);
    }

    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }

Why expose these helpers?

CellToWorld keeps grid-to-scene conversion consistent. FindPathFor provides controlled access to the existing pathfinder.

CreateUnit gives every gameplay unit the same positioning and visual setup.

  • Save GridStrategyGame.cs.
  • Switch back to Unity.
  • Select the Console tab.

You should see no red compilation errors. The public movement API now compiles with the existing board.

Seeing duplicate method errors?

A duplicate means the earlier private helper still exists elsewhere in the class. Keep one copy of each method using the public declarations shown above.

Need a second pair of eyes? Help me remove duplicate helper methods from GridStrategyGame.

The HUD closes the decision loop. It shows the control instructions. It also reports the budget, terrain-cost mode, route result, and movement status.

  • Find the existing OnGUI method near the bottom of GridStrategyGame.cs.
  • Replace that method with this final HUD version:
    private void OnGUI()
    {
        GUI.Label(new Rect(20f, 20f, 900f, 25f),
            "Clic: previsualizar. Segundo clic en el mismo destino: mover.");
        GUI.Label(new Rect(20f, 45f, 900f, 25f),
            "Presupuesto: " + MovementBudget + " | Costes de terreno: " + useTerrainCosts);
        GUI.Label(new Rect(20f, 70f, 900f, 25f), status);
    }

What does the final HUD show?

  • The first label explains the preview and confirmation controls.
  • The second label shows the 18-point budget and current terrain-cost setting.
  • The final label displays route steps, route cost, blocked-cell feedback, or movement status.
  • Save GridStrategyGame.cs.
  • Switch back to Unity.
  • Save Assets/Scenes/GridStrategy.unity by pressing Ctrl+S.

Before the final check, predict which test should move the unit: a wall, an over-budget route, or the same affordable destination clicked twice.

  • Press Play.
  • Click the gray wall near (4, 2).

The HUD should show Ese muro no es transitable. The unit should remain in its current cell.

  • Click the distant green cell near (9, 0) once.

You should see a red route because its cost exceeds the movement budget.

  • Click the same cell near (9, 0) again.

The sphere should stay still. The HUD should report that the route exceeds the budget of 18.

  • Click the reachable cell near (7, 2) once.

You should see a yellow route through the upper opening. The HUD should show 17 as the cost.

  • Click the same cell near (7, 2) again.

You should see the blue sphere follow the route without crossing a wall. The preview should clear when movement finishes.

Final test does not match?

  • Confirm that useTerrainCosts is enabled on GameManager in the Inspector.
  • Confirm that the scene contains only one GameManager with GridStrategyGame attached.
  • Check the Console for a compilation or runtime error after the second click.

Still blocked? Help me debug the final route confirmation and movement test.

That completes the tactical loop. Your unit can now preview a weighted route, reject an expensive move, confirm an affordable destination, and travel through the board.

✔️ Awesome, I've got everything!

Great work. Save GridStrategyGame.cs and Assets/Scenes/GridStrategy.unity before leaving Play Mode.

ⓧ I'd like to double check the full code

Compare your complete GridStrategyGame.cs with this reference. Every identifier and value should match.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GridStrategyGame : MonoBehaviour
{
    private const int Width = 10;
    private const int Height = 8;
    private const int MovementBudget = 18;

    private readonly Dictionary<int, GameObject> tiles = new Dictionary<int, GameObject>();
    private readonly Dictionary<GameObject, int> cellsByTile = new Dictionary<GameObject, int>();
    private readonly HashSet<int> walls = new HashSet<int>();
    private readonly HashSet<int> mud = new HashSet<int>();

    public bool useTerrainCosts = true;

    private Pathfinder pathfinder;
    private Camera gameCamera;
    private GameObject playerUnit;
    private List<int> previewPath = new List<int>();
    private int playerCell;
    private bool moving;
    private string status = "Haz clic en una casilla para calcular una ruta.";

    public int PlayerCell
    {
        get { return playerCell; }
    }

    public bool IsMoving
    {
        get { return moving; }
    }

    public int CellCount
    {
        get { return Width * Height; }
    }

    private void Start()
    {
        ConfigureMap();
        pathfinder = new Pathfinder(Width, Height, walls, mud);
        ConfigureCamera();
        BuildBoard();

        playerCell = ToCell(1, 1);
        playerUnit = CreateUnit(new Color(0.15f, 0.45f, 1f), playerCell, PrimitiveType.Sphere);
        RefreshTileColors();
    }

    private void Update()
    {
        if (moving || !Input.GetMouseButtonDown(0))
        {
            return;
        }

        Ray ray = gameCamera.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        if (!Physics.Raycast(ray, out hit))
        {
            return;
        }

        int clickedCell;

        if (!cellsByTile.TryGetValue(hit.collider.gameObject, out clickedCell))
        {
            return;
        }

        HandleCellClick(clickedCell);
    }

    private void ConfigureMap()
    {
        walls.Add(ToCell(4, 0));
        walls.Add(ToCell(4, 1));
        walls.Add(ToCell(4, 2));
        walls.Add(ToCell(4, 4));
        walls.Add(ToCell(4, 5));
        walls.Add(ToCell(4, 6));

        mud.Add(ToCell(2, 3));
        mud.Add(ToCell(3, 3));
        mud.Add(ToCell(4, 3));
        mud.Add(ToCell(5, 3));
        mud.Add(ToCell(6, 3));
    }

    private void ConfigureCamera()
    {
        gameCamera = Camera.main;

        if (gameCamera == null)
        {
            GameObject cameraObject = new GameObject("Runtime Camera", typeof(Camera));
            gameCamera = cameraObject.GetComponent<Camera>();
        }

        gameCamera.transform.position = new Vector3(
            (Width - 1) * 0.5f,
            10f,
            (Height - 1) * 0.5f);
        gameCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
        gameCamera.orthographic = true;
        gameCamera.orthographicSize = 5.2f;
    }

    private void BuildBoard()
    {
        for (int y = 0; y < Height; y++)
        {
            for (int x = 0; x < Width; x++)
            {
                int cell = ToCell(x, y);
                GameObject tile = GameObject.CreatePrimitive(PrimitiveType.Cube);
                tile.name = "Tile " + x + "," + y;

                bool isWall = walls.Contains(cell);
                tile.transform.position = new Vector3(x, isWall ? 0.3f : 0f, y);
                tile.transform.localScale = new Vector3(0.92f, isWall ? 0.7f : 0.12f, 0.92f);

                tiles.Add(cell, tile);
                cellsByTile.Add(tile, cell);
                SetTileColor(cell, GetBaseColor(cell));
            }
        }
    }

    private void HandleCellClick(int clickedCell)
    {
        if (!pathfinder.IsWalkable(clickedCell))
        {
            previewPath.Clear();
            status = "Ese muro no es transitable.";
            RefreshTileColors();
            return;
        }

        if (previewPath.Count > 0 && previewPath[previewPath.Count - 1] == clickedCell)
        {
            int confirmedCost = pathfinder.GetPathCost(previewPath, useTerrainCosts);

            if (confirmedCost > MovementBudget)
            {
                status = "La ruta supera el presupuesto de " + MovementBudget + ".";
                return;
            }

            StartCoroutine(MoveUnit(new List<int>(previewPath)));
            return;
        }

        previewPath = pathfinder.FindPath(playerCell, clickedCell, useTerrainCosts);

        if (previewPath.Count == 0)
        {
            status = "No existe una ruta hasta esa casilla.";
        }
        else
        {
            int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
            status = "Ruta: " + (previewPath.Count - 1) + " pasos, coste " + cost + ".";
        }

        RefreshTileColors();
    }

    private IEnumerator MoveUnit(List<int> path)
    {
        moving = true;
        status = "Unidad en movimiento.";

        for (int index = 1; index < path.Count; index++)
        {
            Vector3 target = CellToWorld(path[index]);

            while ((playerUnit.transform.position - target).sqrMagnitude > 0.0001f)
            {
                playerUnit.transform.position = Vector3.MoveTowards(
                    playerUnit.transform.position,
                    target,
                    4f * Time.deltaTime);
                yield return null;
            }

            playerUnit.transform.position = target;
            playerCell = path[index];
            RefreshTileColors();
        }

        previewPath.Clear();
        moving = false;
        status = "Movimiento completado. Elige otro destino.";
        RefreshTileColors();
    }

    private void RefreshTileColors()
    {
        foreach (KeyValuePair<int, GameObject> tile in tiles)
        {
            SetTileColor(tile.Key, GetBaseColor(tile.Key));
        }

        int cost = pathfinder.GetPathCost(previewPath, useTerrainCosts);
        Color previewColor = cost <= MovementBudget
            ? new Color(1f, 0.85f, 0.1f)
            : new Color(1f, 0.2f, 0.2f);

        for (int index = 1; index < previewPath.Count; index++)
        {
            SetTileColor(previewPath[index], previewColor);
        }

        SetTileColor(playerCell, new Color(0.1f, 0.65f, 1f));
    }

    private Color GetBaseColor(int cell)
    {
        if (walls.Contains(cell))
        {
            return new Color(0.2f, 0.22f, 0.25f);
        }

        if (mud.Contains(cell))
        {
            return new Color(0.45f, 0.25f, 0.08f);
        }

        return new Color(0.2f, 0.6f, 0.25f);
    }

    private void SetTileColor(int cell, Color color)
    {
        tiles[cell].GetComponent<Renderer>().material.color = color;
    }

    private int ToCell(int x, int y)
    {
        return y * Width + x;
    }

    public Vector3 CellToWorld(int cell, float height = 0.65f)
    {
        int x = cell % Width;
        int y = cell / Width;
        return new Vector3(x, height, y);
    }

    public List<int> FindPathFor(int start, int goal)
    {
        return pathfinder.FindPath(start, goal, useTerrainCosts);
    }

    public GameObject CreateUnit(Color color, int cell, PrimitiveType shape)
    {
        GameObject unit = GameObject.CreatePrimitive(shape);
        unit.transform.position = CellToWorld(cell);
        unit.transform.localScale = shape == PrimitiveType.Sphere
            ? new Vector3(0.55f, 0.55f, 0.55f)
            : new Vector3(0.45f, 0.6f, 0.45f);
        unit.GetComponent<Renderer>().material.color = color;
        return unit;
    }

    private void OnGUI()
    {
        GUI.Label(new Rect(20f, 20f, 900f, 25f),
            "Clic: previsualizar. Segundo clic en el mismo destino: mover.");
        GUI.Label(new Rect(20f, 45f, 900f, 25f),
            "Presupuesto: " + MovementBudget + " | Costes de terreno: " + useTerrainCosts);
        GUI.Label(new Rect(20f, 70f, 900f, 25f), status);
    }
}

Secret mission

Añadir un enemigo perseguidor

Tu tablero ya responde a las decisiones del jugador. Ahora añadirás una cápsula roja que reutiliza A* para avanzar una casilla después de cada movimiento confirmado.

Clean Up Your Resources

Clean Up Your Resources

Your playable prototype lives entirely inside the local GridPathfinder project in Unity. Keeping these files adds no ongoing project cost.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Your local GridPathfinder project folder containing the saved scene and project settings.
  • Your saved tactical scene at Assets/Scenes/GridStrategy.unity.
  • Your pathfinding script at Assets/Scripts/Pathfinder.cs.
  • Your game controller script at Assets/Scripts/GridStrategyGame.cs.
  • Your enemy controller script at Assets/Scripts/EnemyChaser.cs.

Keep everything running

No action needed. Choose this if you want to keep refining your tactical prototype.

  • Keep the GridPathfinder project folder in its current location.
  • Use Assets/Scenes/GridStrategy.unity as the scene to resume from.
  • Click Play in the top toolbar when you want to run the prototype again.

You will see the generated tactical board with the blue player unit and red enemy unit.

Pause - I'll come back to this later

Pausing ends the current simulation while preserving every project file. Your prototype remains ready for another session.

  • Click Play in the top toolbar if the editor is still in Play Mode.
  • Press Ctrl+S to save Assets/Scenes/GridStrategy.unity.
  • Close the Unity Editor window.

The runtime board and units close with the editor. Every file inside GridPathfinder remains available for your next session.

Delete - I don't want to use this again

Deleting this folder removes only this prototype. Your Unity installation stays available for other projects.

  • Close the Unity Editor window.

The editor is now out of the way. Next, remove the complete project folder from Windows.

  • Press the Windows key to open search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Search for GridPathfinder in File Explorer.
  • Select the project folder that contains Assets/Scenes/GridStrategy.unity.
  • Right-click the selected GridPathfinder folder.
  • Select Delete from the context menu.

The folder has left its original location. One final Windows cleanup removes the recoverable copy.

  • Return to the Windows desktop.
  • Right-click Recycle Bin.
  • Select Empty Recycle Bin.
  • Confirm the deletion when Windows asks.
  • Search for GridPathfinder in File Explorer once more.

You should see no remaining GridPathfinder project folder. That clears the complete prototype while preserving your other Unity projects.

Nice Work!

Nice Work!

¡Misión cumplida! Has construido un prototipo táctico jugable de A* en Unity que convierte cada ruta en una decisión visible.

Has aprendido a:

  • Modelar una cuadrícula discreta de 10 por 8 mediante código. Sus casillas muestran terreno normal, barro o muros.
  • Implementar un pathfinder A* con coste acumulado. La heurística Manhattan guía la búsqueda. Los costes ponderados permiten evitar el barro cuando existe una ruta más barata.
  • Conectar los clics con el tablero mediante raycasting. La vista previa muestra la ruta antes de confirmar. Una corrutina mueve la unidad casilla por casilla dentro de su presupuesto.
  • Secret Mission: Reutilizaste el mismo pathfinder para crear un enemigo perseguidor. La cápsula roja recalcula su ruta después de cada movimiento del jugador.

Ready to quiz yourself?