Skip to content

Recipe ‐ Paint the Town: Painting Roads with GPS tracking

Paul Huemer edited this page Feb 8, 2025 · 1 revision

Recipe - Paint the Town: Painting Roads with GPS tracking

This recipe will guide you through creating a Unity project that allows you to "paint" roads as you walk on them in the real world. The project uses OpenStreetMap (OSM) data for road segments and saves your progress persistently. It should encourage exploring new roads that you have never been to before.

Ingredients

  • Unity (version 2022.3.56f1 or later)
  • OpenStreetMap data
  • GPS-enabled device (smartphone or tablet)
  • Basic understanding of C# and Unity

Setup

  1. Install Unity:

    • Download and install Unity from the official website.
    • Create a new 3D project in Unity. (v2022.3.56f1)
  2. Install Required Packages:

    • Open the Package Manager in Unity (Window > Package Manager).
    • Install the following packages:
      • TextMesh Pro
      • Unity UI
      • JSON .NET (for JSON serialization)
      • Android Logcat (optional: sends debug logs from your android device to the unity editor)
  3. Project Structure:

    • Organize your project with the following folders:
      • Assets/Scripts: For all your C# scripts.
      • Assets/Prefabs: For prefabs like the map tile.
      • Assets/Scenes: For your Unity scenes.
      • Assets/Materials: For materials like road textures.
  4. Build settings:

    • Go into your Build Settings and set the platform to Android.

    • If you use a phone with USB debugging for testing, select the phone in "Run Device".

    • Go to Player Settings in the bottom left.

      • In the Android Tab go to Publishing Settings > Custom Main Manifest. Enable it.

      • Add the following code to your AdroidManifest.xml:

   <?xml version="1.0" encoding="utf-8"?>
   <manifest xmlns:android="http://schemas.android.com/apk/res/android"  
             package="com.yourcompany.yourgame">  
       <!-- Required Permissions -->  
       <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>  
       <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>  

       <!-- For background location (only needed if tracking in the background) -->  
       <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>  

       <application>        <activity                android:name="com.unity3d.player.UnityPlayerActivity"  
                   android:label="@string/app_name"  
                   android:theme="@style/UnityThemeSelector"  
                   android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize"  
                   android:launchMode="singleTask">  

               <!-- Main entry point for the app -->  
               <intent-filter>  
                   <action android:name="android.intent.action.MAIN" />  
                   <category android:name="android.intent.category.LAUNCHER" />  
               </intent-filter>        </activity>  
       </application>

   </manifest>

Instructions

Unfortunately, unless you have a GPS enabled device on which you set up the Unity project, you won't be able to see much in the unity editor itself. To test your app, you will have to enable developer mode and usb debugging on a second device, like your android phone. You can build and run the app to it and see the debug logs using the Android Logcat.

Step 1: Set Up the Scene

  1. Create a new scene in Unity (File > New Scene).
  2. Save the scene as MainScene in the Assets/Scenes folder.

Step 2: Create the GPSManager Script

  • GPSManager: Handles GPS data to update the player's position on the map. using UnityEngine; using System.Collections; using UnityEngine.UI; public class GPSManager : MonoBehaviour {

      public TileManager tileManager;
      public PlayerMarker playerMarker;
      private bool gpsInitialized = false;
      private float updateInterval = 0.2f; // Update GPS every second
      public float lat = 0;
      public float lon = 0;
      private float testLat = 0;
      void Start()
      {
          if (tileManager == null)
          {
              tileManager = FindObjectOfType<TileManager>();
              if (tileManager == null)
              {
                  Debug.LogError("TileManager not found! Make sure it is instantiated and available.");
                  return;
              }
          }
          StartCoroutine(StartGPS());
      }
      IEnumerator StartGPS()
      {
          if (!Input.location.isEnabledByUser)
          {
              Debug.LogError("GPS is disabled. Enable location services.");
              yield break;
          }
          Input.location.Start(0.1f, 0.1f);
          int maxWait = 10;
          while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0)
          {
              yield return new WaitForSeconds(1);
              maxWait--;
          }
          if (Input.location.status == LocationServiceStatus.Failed)
          {
              Debug.LogError("Failed to get GPS location.");
              yield break;
          }
    
          Debug.Log("GPS initialized successfully.");
          gpsInitialized = true;
    
          StartCoroutine(UpdateGPS());
      }
    
      IEnumerator UpdateGPS()
      {
          while (gpsInitialized)
          {
              if (Input.location.status == LocationServiceStatus.Running)
              {
                  testLat += 0.0001f;
                  lat = Input.location.lastData.latitude + testLat;
                  lon = Input.location.lastData.longitude;
    
                  int newTileX, newTileY;
                  ConvertLatLonToTile(lat, lon, tileManager.zoom, out newTileX, out newTileY);
    
                  // Only reload tiles if the tile position changed
                  if (newTileX != tileManager.tileX || newTileY != tileManager.tileY)
                  {
                      tileManager.tileX = newTileX;
                      tileManager.tileY = newTileY;
                      tileManager.LoadTilesAround(new Vector2Int(newTileX, newTileY));  // Reload adjacent tiles
                  }
    
                  Vector3 targetPosition = ConvertGPSPositionToMap(lat, lon);
                  playerMarker.UpdatePosition(targetPosition);
              }
              else
              {
                  Debug.LogWarning("GPS lost signal. Waiting for recovery...");
              }
    
              yield return new WaitForSeconds(updateInterval);
          }
      }
    
      public void ConvertLatLonToTile(float lat, float lon, int zoom, out int tileX, out int tileY)
      {
          tileX = (int)((lon + 180.0) / 360.0 * (1 << zoom));
          tileY = (int)((1f - Mathf.Log(Mathf.Tan(lat * Mathf.PI / 180f) + 1f / Mathf.Cos(lat * Mathf.PI / 180f)) / Mathf.PI) / 2f * (1 << zoom));
      }
    
      Vector3 ConvertGPSPositionToMap(float lat, float lon)
      {
          int zoom = tileManager.zoom;
          int tileX, tileY;
    
          tileManager.ConvertLatLonToTile(lat, lon, zoom, out tileX, out tileY);
    
          float numTiles = Mathf.Pow(2, zoom);
          float tileFloatX = (lon + 180f) / 360f * numTiles;
          float tileFloatY = (1f - Mathf.Log(Mathf.Tan(lat * Mathf.PI / 180f) + 1f / Mathf.Cos(lat * Mathf.PI / 180f)) / Mathf.PI) / 2f * numTiles;
    
          // Ensure player's world position matches the tile system
          float localX = (tileFloatX - tileX) * tileManager.tileSize;
          float localY = (tileFloatY - tileY) * tileManager.tileSize;
          localY = -localY;  // Flip Y to match Unity's coordinate system
    
          // Offset world position based on tile index
          float worldX = (tileX - tileManager.tileX) * tileManager.tileSize - tileManager.tileSize / 2 + localX;
          float worldY = (tileManager.tileY - tileY) * tileManager.tileSize + tileManager.tileSize / 2 + localY;
    
          Vector3 worldPos = new Vector3(worldX, 0.2f, worldY);
          return worldPos;
      }
    
      private void OnApplicationQuit()
      {
          Input.location.Stop();
          Input.compass.enabled = false;
          Input.gyro.enabled = false;
      }

    }

  • Variables:

    • tileManager: Reference to the TileManager script.
    • playerMarker: Reference to the PlayerMarker script.
    • gpsInitialized: Flag to check if GPS is initialized.
    • updateInterval: Interval for updating GPS data.
    • latlon: Latitude and longitude coordinates.
    • testLat: Test latitude for simulation.
    • StartGPS: Initializes the GPS.
    • UpdateGPS: Updates the GPS data.
    • ConvertLatLonToTile: Converts latitude and longitude to tile coordinates.
    • ConvertGPSPositionToMap: Converts GPS position to map coordinates.
    • OnApplicationQuit: Stops the GPS when the application quits.

Create an empty game object, name it "GPSManager" and add this script to it.

Step 3: Create the TileManager Script

  • TileManager: Manages the loading of map tiles and road data around the current tile.
using UnityEngine;
using System.Collections;
public class TileManager : MonoBehaviour
{
    public int tileX;
    public int tileY;
    public int zoom = 16;
    public float tileSize = 256f;
    public TileLoader tileLoader;
    public OSMDataFetcher osmDataFetcher;
    void Start()
    {
        LoadTilesAround(new Vector2Int(tileX, tileY));
    }
    public void LoadTilesAround(Vector2Int tileCoords)
    {
        for (int x = -1; x <= 1; x++)
        {
            for (int y = -1; y <= 1; y++)
            {
                int tileX = tileCoords.x + x;
                int tileY = tileCoords.y + y;
                tileLoader.LoadTile(tileX, tileY, zoom);
                osmDataFetcher.LoadRoadsForNewTile(new Vector2Int(tileX, tileY));
            }
        }
    }
    public void ConvertLatLonToTile(float lat, float lon, int zoom, out int tileX, out int tileY)
    {
        tileX = (int)((lon + 180.0) / 360.0 * (1 << zoom));
        tileY = (int)((1f - Mathf.Log(Mathf.Tan(lat * Mathf.PI / 180f) + 1f / Mathf.Cos(lat * Mathf.PI / 180f)) / Mathf.PI) / 2f * (1 << zoom));
    }
}
  • Variables:
    • tileXtileY: Current tile coordinates.
    • zoom: Zoom level for the map tiles.
    • tileSize: Size of each tile.
    • tileLoader: Reference to the TileLoader script.
    • osmDataFetcher: Reference to the OSMDataFetcher script.
    • LoadTilesAround: Loads tiles around the current tile.
    • ConvertLatLonToTile: Converts latitude and longitude to tile coordinates.

Create an empty game object, name it "Tile Manager" and add this script to it.

Step 4: Create the TileLoader Script

  • TileLoader: Loads map tiles from OpenStreetMap and applies them to the map plane.
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
public class TileLoader : MonoBehaviour
{
    public Renderer mapRenderer; // The plane or UI element to display the map
    private string tileServerURL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
    public void LoadTile(int x, int y, int zoom)
    {
        StartCoroutine(LoadTileTexture(x, y, zoom));
    }
    private IEnumerator LoadTileTexture(int x, int y, int zoom)
    {
        string url = tileServerURL.Replace("{z}", zoom.ToString())
                                  .Replace("{x}", x.ToString())
                                  .Replace("{y}", y.ToString());
        using (UnityWebRequest request = UnityWebRequestTexture.GetTexture(url))
        {
            yield return request.SendWebRequest();
            if (request.result == UnityWebRequest.Result.Success)
            {
                Texture2D tileTexture = DownloadHandlerTexture.GetContent(request);
                mapRenderer.material.SetTexture("_MainTex", tileTexture);
                transform.localRotation = Quaternion.Euler(0, 180, 0);
            }
            else
            {
                Debug.LogError("Failed to load tile: " + request.error);
            }
        }
    }
    private Texture2D FlipTexture(Texture2D original)
    {
        Texture2D flipped = new Texture2D(original.width, original.height);
        for (int i = 0; i < original.width; i++)
        {
            for (int j = 0; j < original.height; j++)
            {
                flipped.SetPixel(i, j, original.GetPixel(original.width - i - 1, original.height - j - 1));
            }
        }
        flipped.Apply();
        return flipped;
    }
}
  • Variables:
    • mapRenderer: Reference to the map plane's renderer.
    • tileServerURL: URL template for loading map tiles.
    • LoadTile: Loads a tile at the specified coordinates and zoom level.
    • LoadTileTexture: Downloads the tile texture and applies it to the map plane.
    • FlipTexture: Flips the texture to match Unity's coordinate system.

After creating the script, add a plane with the dimensions 10x10 to your scene. add the Tile Loader script to it and assign the Mesh Renderer to the Tile Loader. Save this Plane as a prefab and name it "Map".

At this point, your Unity project in play mode should look something like this:

Tile Loader

Dont't mind the white tiles. The Tile Manager defaults to the tile (0, 0), since it can't gather GPS Data from within the Unity project. The GPSManager handles the GPS Location and sends it to the Tile Manager. However, this will only change something in the Unity project if you are working on a GPS enabled device. If you now build your project to your GPS enabled device, like an android phone, it should look something like this (note that this should be your current location):

GPS Tiles on Mobile

Step 5: Create the OSMDataFetcher Script

  • OSMDataFetcher: Fetches road data from OpenStreetMap using the Overpass API.
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Collections.Generic;
using System;
public class OSMDataFetcher : MonoBehaviour
{
    public string overpassAPI = "https://overpass-api.de/api/interpreter";
    public TileManager tileManager;
    public Material roadMaterial; // Assign a simple material in the Inspector
    public RoadPainter roadPainter;
    public List<Vector3> roadPositions = new List<Vector3>();
    private HashSet<Vector2Int> loadedTiles = new HashSet<Vector2Int>();
    private Dictionary<Vector2Int, List<Vector3[]>> cachedRoads = new Dictionary<Vector2Int, List<Vector3[]>>();
    private Dictionary<Vector2Int, List<GameObject>> renderedRoads = new Dictionary<Vector2Int, List<GameObject>>();
    void Start()
    {
        if (tileManager == null)
        {
            tileManager = FindObjectOfType<TileManager>();  // Find TileLoader in the scene
            if (tileManager == null)
            {
                Debug.LogError("tileManager not found! Make sure it is instantiated and available.");
                return;
            }
        }
    }
    public void LoadRoadsForNewTile(Vector2Int tileCoords)
    {
        StartCoroutine(FetchRoadDataForTile(tileCoords));
    }
    IEnumerator FetchRoadDataForTile(Vector2Int tileCoords)
    {
        if (!cachedRoads.ContainsKey(tileCoords)) // Skip if already loaded
        {
            float minLat, minLon, maxLat, maxLon;
            ConvertTileToBoundingBox(tileCoords.x, tileCoords.y, tileManager.zoom, out minLat, out minLon, out maxLat, out maxLon);
            string query = $"[out:json];(way[\"highway\"~\"^(primary|secondary|tertiary|residential)$\"]({minLat},{minLon},{maxLat},{maxLon});node(w););out;";
            string url = $"{overpassAPI}?data={UnityWebRequest.EscapeURL(query)}";

            UnityWebRequest request = UnityWebRequest.Get(url);
            yield return request.SendWebRequest();

            if (request.result == UnityWebRequest.Result.Success)
            {
                string jsonData = request.downloadHandler.text;
                List<Vector3[]> roadSegments = ParseRoadData(jsonData, tileCoords);
                roadPainter.cachedRoads[tileCoords] = roadSegments;
                roadPainter.UnloadRoads();
                roadPainter.DrawRoads();  // Refresh the roads
            }
            else
            {
                Debug.LogError($"Failed to fetch OSM data for tile {tileCoords}: {request.error}");
            }
        }
    }

    List<Vector3[]> ParseRoadData(string jsonData, Vector2Int tileCoords)
    {
        List<Vector3[]> roadSegments = new List<Vector3[]>();
        Dictionary<int, Vector3> nodePositions = new Dictionary<int, Vector3>();

        OSMResponse response = JsonUtility.FromJson<OSMResponse>(jsonData);

        // Store all nodes
        foreach (var element in response.elements)
        {
            if (element.type == "node")
            {
                Vector3 worldPos = ConvertLatLonToUnity(element.lat, element.lon, tileCoords.x, tileCoords.y);
                nodePositions[element.id] = worldPos;
            }
        }

        // Process ways using stored nodes
        foreach (var element in response.elements)
        {
            if (element.type == "way" && element.nodes.Count > 1)
            {
                for (int i = 0; i < element.nodes.Count - 1; i++)
                {
                    if (!nodePositions.ContainsKey(element.nodes[i]) || !nodePositions.ContainsKey(element.nodes[i + 1]))
                        continue;

                    Vector3 start = nodePositions[element.nodes[i]];
                    Vector3 end = nodePositions[element.nodes[i + 1]];
                    float distance = Vector3.Distance(start, end);

                    // Filter out too long segments
                    if (distance > 3f)
                    {
                        continue;
                    }

                    // Split remaining segments into smaller pieces
                    float segmentLength = 0.2f;
                    int numSegments = Mathf.Max(1, Mathf.CeilToInt(distance / segmentLength));

                    Vector3 previousPoint = start;

                    for (int j = 1; j <= numSegments; j++) // Start from 1 to avoid duplicate start point
                    {
                        float t = j / (float)numSegments;
                        Vector3 interpolatedPoint = Vector3.Lerp(start, end, t);

                        // Add each small segment as a separate road segment
                        roadSegments.Add(new Vector3[] { previousPoint, interpolatedPoint });

                        previousPoint = interpolatedPoint;
                    }
                }
            }
        }

        Debug.Log($"Loaded {roadSegments.Count} road segments for tile {tileCoords}");
        return roadSegments;
    }

    void ConvertTileToBoundingBox(int tileX, int tileY, int zoom, out float minLat, out float minLon, out float maxLat, out float maxLon)
    {
        float numTiles = Mathf.Pow(2, zoom);
        minLon = tileX / numTiles * 360f - 180f;
        maxLon = (tileX + 1) / numTiles * 360f - 180f;

        // Cast lat and lon to double before passing to Math.Sinh
        minLat = Mathf.Atan((float)Math.Sinh(Math.PI * (1 - 2 * (tileY + 1) / numTiles))) * Mathf.Rad2Deg;
        maxLat = Mathf.Atan((float)Math.Sinh(Math.PI * (1 - 2 * tileY / numTiles))) * Mathf.Rad2Deg;
    }

    Vector3 ConvertLatLonToUnity(float lat, float lon, int tileX, int tileY)
    {
        int zoom = tileManager.zoom;
        float numTiles = Mathf.Pow(2, zoom);

        float tileFloatX = (lon + 180f) / 360f * numTiles;
        float tileFloatY = (1f - Mathf.Log(Mathf.Tan(lat * Mathf.PI / 180f) + 1f / Mathf.Cos(lat * Mathf.PI / 180f)) / Mathf.PI) / 2f * numTiles;

        // Convert global tile position to local tile position
        float localX = (tileFloatX - tileX) * tileManager.tileSize;
        float localY = (tileFloatY - tileY) * tileManager.tileSize;
        localY = -localY;  // Flip Y to match Unity's coordinate system

        // Offset world position based on tile index
        float worldX = (tileX - tileManager.tileX) * tileManager.tileSize - tileManager.tileSize / 2 + localX;
        float worldY = (tileManager.tileY - tileY) * tileManager.tileSize + tileManager.tileSize / 2 + localY;

        Vector3 worldPos = new Vector3(worldX, 0.1f, worldY);
        return worldPos;
    }

    [System.Serializable]
    public class OSMResponse
    {
        public List<OSMElement> elements;
    }

    [System.Serializable]
    public class OSMElement
    {
        public string type;
        public int id;
        public List<int> nodes; // Only for ways
        public float lat; // Only for nodes
        public float lon; // Only for nodes
    }

    [System.Serializable]
    public class OSMNode
    {
        public float lat;
        public float lon;
    }
}
  • Variables:
    • overpassAPI: URL for the Overpass API.
    • tileManager: Reference to the TileManager script.
    • roadMaterial: Material for the road segments.
    • roadPainter: Reference to the RoadPainter script.
    • roadPositions: List of road positions.
    • loadedTiles: Set of loaded tiles.
    • cachedRoads: Dictionary of cached road segments.
    • renderedRoads: Dictionary of rendered road segments.
    • LoadRoadsForNewTile: Loads road data for a new tile.
    • FetchRoadDataForTile: Fetches road data for a tile from the Overpass API.
    • ParseRoadData: Parses the JSON data to extract road segments.
    • ConvertTileToBoundingBox: Converts tile coordinates to a bounding box.
    • ConvertLatLonToUnity: Converts latitude and longitude to Unity coordinates.

Create an empty game object, name it "OSMDataFetcher" and add this script to it.

Step 6: Create the RoadPainter Script

  • RoadPainter: Draws road segments on the map using the cached road data.
`using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System;
public class RoadPainter : MonoBehaviour
{
    public Dictionary<Vector2Int, List<Vector3[]>> cachedRoads = new Dictionary<Vector2Int, List<Vector3[]>>();
    private Dictionary<Vector2Int, List<GameObject>> renderedRoads = new Dictionary<Vector2Int, List<GameObject>>();
    public TileManager tileManager;
    public Material roadMaterial;
    public SavingManager savingManager;
    public void DrawRoads()
    {
        foreach (var tile in cachedRoads.Keys)
        {
            if (!cachedRoads.ContainsKey(tile)) continue; // Skip tiles with no roads
            List<GameObject> roadObjList = new List<GameObject>();
            foreach (Vector3[] segment in cachedRoads[tile])
            {
                GameObject roadObj = new GameObject($"RoadSegment {tile} {segment[0].x + segment[0].y + segment[0].z + segment[1].x + segment[1].y + segment[1].z}");
                LineRenderer lineRenderer = roadObj.AddComponent<LineRenderer>();
                RoadSegmentTrigger roadSegmentTrigger = roadObj.AddComponent<RoadSegmentTrigger>();
                roadSegmentTrigger.savingManager = savingManager;
                roadSegmentTrigger.tile = tile;
                BoxCollider collider = roadObj.AddComponent<BoxCollider>();
                lineRenderer.positionCount = segment.Length;
                lineRenderer.SetPositions(segment);
                lineRenderer.startWidth = 0.05f;
                lineRenderer.endWidth = 0.05f;
                lineRenderer.material = roadMaterial;
                lineRenderer.useWorldSpace = true;  // Ensure world space positioning
                lineRenderer.sortingOrder = 10;  // Ensure it's drawn above the map
                float segmentLength = Vector3.Distance(segment[0], segment[1]);
                Vector3 midPoint = (segment[0] + segment[1]) / 2;
                collider.center = midPoint;
                collider.size = new Vector3(0.05f, 1f, segmentLength);
                collider.isTrigger = true;

                roadObjList.Add(roadObj);

                if (savingManager.walkedOnRoads.ContainsKey(tile) && savingManager.walkedOnRoads[tile].Contains(roadObj.name))
                {
                    lineRenderer.enabled = true;
                }
            }
            if (roadObjList.Count > 0)
            {
                renderedRoads[tile] = roadObjList;
            }
        }
    }

    public void UnloadRoads()
    {
        foreach (var tile in renderedRoads.Keys)
        {
            if (tile == new Vector2(tileManager.tileX, tileManager.tileY)) continue;

            foreach (GameObject roadObj in renderedRoads[tile])
            {
                if (roadObj == null) continue; // Skip if null
                LineRenderer lineRenderer = roadObj.GetComponent<LineRenderer>();
                BoxCollider collider = roadObj.GetComponent<BoxCollider>();
                if (lineRenderer == null) continue; // Skip if missing component

                Destroy(roadObj);
            }
            cachedRoads.Remove(tile);
        }
    }
}`
  • Variables:
    • cachedRoads: Dictionary of cached road segments.
    • renderedRoads: Dictionary of rendered road segments.
    • tileManager: Reference to the TileManager script.
    • roadMaterial: Material for the road segments.
    • savingManager: Reference to the SavingManager script.
    • DrawRoads: Draws road segments on the map.
    • UnloadRoads: Unloads road segments that are not in the current tile.

Create an empty game object, name it "Road Painter" and add this script to it.

Step 7: Create the RoadSegmentTrigger Script

  • RoadSegmentTrigger: Detects when the player enters a road segment and saves the progress.
using UnityEngine;
public class RoadSegmentTrigger : MonoBehaviour
{
    private LineRenderer lineRenderer;
    public SavingManager savingManager;
    public Vector2Int tile;
    void Start()
    {
        lineRenderer = GetComponent<LineRenderer>();
        lineRenderer.enabled = false; // Hide by default
    }
    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))  // Ensure the player has the "Player" tag
        {
            if (lineRenderer.enabled == false && savingManager != null)
            {
                savingManager.saveWalkedOnRoad(gameObject, tile);
            }
            lineRenderer.enabled = true;  // Show the segment when the player enters
        }
    }
}
  • Variables:
    • lineRenderer: Reference to the LineRenderer component.
    • savingManager: Reference to the SavingManager script.
    • tile: Tile coordinates for the road segment.
    • OnTriggerEnter: Detects when the player enters the road segment.

This trigger will be assigned to each road segment as they are instantiated in the "RoadPainter" script.

Step 8: Create the PlayerMarker Script

  • PlayerMarker: Updates the position of the player marker on the map.
using UnityEngine; public class PlayerMarker : MonoBehaviour {
     public void UpdatePosition(Vector3 newPosition)
     {         
          transform.position = newPosition;
     } 
}
  • Variables:
    • UpdatePosition: Updates the position of the player marker.

Create a Quad Mesh and name it "PlayerMarker". Add the player marker script to it. create a sprite material with a playerMarker icon like we now it from google maps or similar apps. Add a Mesh collideer to it, so it can collide with the road segments and make them visible.

Player Marker

This should now allow you to walk around and paint roads! At this point, the project is already done if you have already assigned all references in the scripts in the Unity editor and you do not need persistant data saving. In the next step, a SavingManager is added, so the data is saved in a file and loaded on startup. If you want to change the appearence of your painted roads, change the lineRenderer attributes in the RoadPainter function "DrawRoads".

Step 9: Create the SavingManager Script

SavingManager: Saves and loads the progress of walked-on roads.

  using System.Collections;
  using System.Collections.Generic;
  using UnityEngine;
  using System;
  using System.IO;
  using UnityEngine.UI;
  using TMPro;
  [Serializable]
  public class SavedRoadData
  {
  public Vector2Int tile;
  public List<string> roadObjNames = new List<string>();  // Save names or unique IDs
  }
  [Serializable]
  public class SavedRoads
  {
  public List<SavedRoadData> roads = new List<SavedRoadData>();  // List of all saved roads
  }
  public class SavingManager : MonoBehaviour
  {
  private string savePath;
  public Dictionary<Vector2Int, List<string>> walkedOnRoads = new Dictionary<Vector2Int, List<string>>();
  public TMP_Text roadSegmentCount;
  public TMP_Text tilesVisitedCount;
  private void Awake()
  {
      savePath = Path.Combine(Application.persistentDataPath, "walkedOnRoads.json");
      LoadWalkedOnRoads();
      InvokeRepeating(nameof(UpdateRoadSegmentCount), 0f, 5f);
  }
  public void saveWalkedOnRoad(GameObject roadObj, Vector2Int tile)
  {
      if (!walkedOnRoads.ContainsKey(tile))
      {
          walkedOnRoads[tile] = new List<string>();
      }
      walkedOnRoads[tile].Add(roadObj.name);
      SaveWalkedOnRoads();  // Save after every change
  }
  private void SaveWalkedOnRoads()
  {
      SavedRoads savedData = new SavedRoads();
      foreach (var entry in walkedOnRoads)
      {
          SavedRoadData roadData = new SavedRoadData();
          roadData.tile = entry.Key;
          roadData.roadObjNames = entry.Value;
          savedData.roads.Add(roadData);
      }

      string json = JsonUtility.ToJson(savedData, true);
      File.WriteAllText(savePath, json);
      Debug.Log("Saved walked roads to: {savePath}");
  }

  private void LoadWalkedOnRoads()
  {
      Debug.Log("Attempting to load from: {savePath}"); // Add this line

      if (File.Exists(savePath))
      {
          Debug.Log("Save file found, reading..."); // Add this line

          string json = File.ReadAllText(savePath);
          SavedRoads loadedData = JsonUtility.FromJson<SavedRoads>(json);

          walkedOnRoads.Clear();
          foreach (SavedRoadData savedRoad in loadedData.roads)
          {
              walkedOnRoads[savedRoad.tile] = savedRoad.roadObjNames;
          }

          Debug.Log("Loaded walked roads from file.");
      }
      else
      {
          Debug.Log("No save file found.");
      }
  }

  private void UpdateRoadSegmentCount()
  {
      roadSegmentCount.text = "Segments collected: {GetTotalRoadSegmentCount()}";
      tilesVisitedCount.text = "Tiles visited: {walkedOnRoads.Count}";
  }

  private int GetTotalRoadSegmentCount()
  {
      int count = 0;
      foreach (var entry in walkedOnRoads)
      {
          count += entry.Value.Count; // Count all road object names in lists
      }
      return count;
  }

}

Variables:

  • savePath: Path to the save file.
  • walkedOnRoads: Dictionary of walked-on road segments.
  • roadSegmentCount: UI text for road segment count.
  • tilesVisitedCount: UI text for tiles visited count.
  • Awake: Initializes the save path and loads walked-on roads.
  • saveWalkedOnRoad: Saves the walked-on road segment.
  • SaveWalkedOnRoads: Saves the walked-on roads to a JSON file.
  • LoadWalkedOnRoads: Loads the walked-on roads from a JSON file.
  • UpdateRoadSegmentCount: Updates the UI with the road segment count.
  • GetTotalRoadSegmentCount: Gets the total count of road segments.

Create an empty game object, name it "SavingManager" and add this script to it.

Step 10: Set Up the UI (Optional)

  1. Create a Canvas in your scene (GameObject > UI > Canvas).
  2. Set the Canvas Render mode to "Screen Space - Camera" and the UI Scale Mode to "Scale with Screen size". Set the reference resolution to your usual mobile device, i went with 640x960.
  3. Add your prefered UI Elements to it. For Texts you can add them in the scripts above to dynamically change the text displayed in them. This is optional though.

Step 11: Assign References in the Inspector

  1. Assign the appropriate references in the Inspector for all the scripts if you haven't done that yet (e.g., tileManagerroadMaterialsavingManager, etc.).
  2. Ensure that all GameObjects have the correct tags (e.g., the player GameObject should have the "Player" tag).

Step 12: Run the Project

  1. Save all your scripts and scenes.
  2. Build the project to your GPS enabled device.

It could look like something like this:

Finished Game

Conclusion

You have now created a Unity project that allows you to "paint" roads as you walk on them in the real world. The project uses OpenStreetMap (OSM) data for road segments and saves your progress persistently. This encourages exploring new roads and painting them as well.

Clone this wiki locally