-
Notifications
You must be signed in to change notification settings - Fork 2
Recipe ‐ Geospatial Ocultist
This recipe should guide you through the creation of a Unity project that functions as a prototype for an application, that should motivate people to walk more by providing a little minigame interacting with objects placed on the digital map. The project uses Niantic Maps and ARDK.
- Unity (version 2022.3.50f1 or later)
- Niantic Lightship Maps SDK
- Niantic ARDK
- Handheld GPS-enabled device
- An account on https://lightship.dev/ to supply an API key for the Maps SDK
-
Download and install a working Unity-version from the official website
-
Use the Unity Hub to create a new 3D(Universal) project
-
In your Unity project open the Package Manager by selecting Window > Package Manager:
- From the plus menu on the Package Manager tab, select Add package from git URL...
- Enter https://github.com/niantic-lightship/maps-upm.git for the Maps SDK
- Enter https://github.com/niantic-lightship/ardk-upm.git for the ARDK
- Enter https://github.com/niantic-lightship/sharedar-upm.git for the SharedAR package
-
Adding your API Key to your Unity Project:
-
Visit lightship.dev
-
Sign in and visit the Projects section. Select an existing project or create a new one with New Project.
-
Under your project Overview, click the copy icon next to your API Key.
-
Open the API Key Helper in Unity by selecting Lightship > Maps SDK > API Key Helper.
-
In the API Key Helper, if your Current API Key is blank, or you want to replace it with the key you just copied, paste the new key next to Lightship API Key and press Setup.
-
-
Build settings:
- In the Build Settings set the platform to Android
- Change Active Input Handling to Both
-
Add Basic Unity Project Structure and/or the Base Third-Person Sample:
There is more than one way to go about this as the basis of the used scene structure is very similar to one of the samples provided in the Lightship Maps SDK. To access this sample simply open the Package Manager as before by selecting Window > Package Manager, then select the Lightship Maps SDK under the rider Samples and import the Third-Person Camera Sample. This recipe will go over the steps that you could take to come to the state of the sample yourself but the base is already there.
Additionally we can add some folders for our own additions to the project:
-
Assets/Project/Scenes: For our own Unity Scenes which will mainly be our map scene -
Assets/Project/Scripts: A place for all of our C# scripts -
Assets/Project/Materials: A folder to place materials, these are for our own map theme and for the prefabs that need models -
Assets/Project/Prefabs: For prefabs that we place on the map, the accompanying spawner and our own map theme, but later more on that.
-
This should be all the setup that has to be done outside of the scene for now, so we can start creating a scene and adding to it.
So we now create a scene and as a first step we add all that is needed to start rendering a map and have the players position represented like the Third-Person Sample provided by Niantic, this section can be skipped if the sample is used as a basis instead.
-
Create a new scene in Unity (File > New Scene or Right Click > Create > New Scene) and name the scene MapScene.
-
Now in the Scene we are going to add some prefabs from the Niantic Maps Packages that we have added through our Package Manager, by searching for the specific prefab using the search bar. We are going to care for the references that need to be set after we have added all necessary prefabs:
-
LightshipMap: This is the first prefab we are going to just drag into our hierarchy. It has most of the underlying logic contained within. It gets the Postionial Data and creates the map tiles based on that data. For testing within the Unity Scene we are going to activate the toggle Start at Default Location so we do not rely on GPS-data while testing. -
Player: Then we drag the Sample player prefab out into our scene, it is recognizable by the little yeti thumbnail. We could create our own PlayerController, but for our purposes the sample player is perfectly suitable. In practice it follows the GPS If we wanted to change the model, we could change the prefab directly by changing out the yeti model for something different, but for ease of use and because it has animations already attached we are going to stick to the yeti for this recipe. -
OrbitCamera:The last part of the basic setup hierarchy the OrbitCamera, it provides a simple somewhat Third-Person view of the transform it has the reference to which will be the Player character in our case and we will handle those references shortly.
-
-
Now we need to handle the cross references in the added prefabs. We do this by selecting them in the hierarchy and simply dragging the appropriate other object into the missing reference field:
- The
Playerneeds a reference to theLightshipMapand a camera. Simply drag theLightshipMap-object from the hierarchy on to the Lightship Map View field and theOrbitCamera's Camera component on to the Camera field. - The
OrbitCameraneeds a reference to thePlayer. Simply drag thePlayer-object from the hierarchy on to the missing reference field.
- The
Starting the scene in the unity editor should now look something like this:
To change the look of our map we have to customize our map theme. We can do this by either using one of the sample map themes provided through the maps package or by customizing a theme for ourselves. We can search up and use a preexisting theme by simply typing "theme" into the search bar and adding one of the results that also say "URP" to our lightship map. You can start out customizing by making a prefab copy of an existing theme or by generating your own prefab.
Creating our own theme from ground up is not hard either, even though you have more to consider you can add or leave out as much of the map as you want. This is done by creating a Prefab that we might call MyMapTheme, adding an existing script component called Map Theme onto the now still empty Game Object within the prefab. In there you have to add the builders that determine what is visualized/built you can do that by adding them through the plus icon next to Builders, opening a dropdown where you can select what you want to be built.
Let's say we want a map that only contains ground, buildings and roads. For the ground we add the GroundBuilderAsync for which we only have to define a URP-material. For the roads a LinearFeatureBuilderAsync for which we have to set the Map Layer to Roads and specifically make it so the shown features are Major Roads and Minor Roads by adding them as Layer Features.
For the Buildings a StructureFeatureBuilderAsync where we again have to add a material and we can add a minimum height so that buildings seem less flat in our scene.
Going forward with the recipe I would suggest to copy one of the sample themes, rename it to MyMapTheme and to swap out the materials in the respective builders if you want the map to have a different look.
Now we are going to add an additional GameObject to our map theme, a prefab spawner, using the SDK's Prefab Spawner map feature, which we have slightly modified, along with a relatively simple GameState script that helps us decide when the prefabs should be spawned and when they should not. Addtionally the GameState script establishes an enum StructureType that may not be of much use now as we have only one type of structure but that could be easily expanded upon. The existing structure as well as the spawning enabling bool are set in Dictionaries so they can be checked for each structure type individually.
using System;
using JetBrains.Annotations;
using Niantic.Lightship.Maps.Builders.Standard.Objects;
using Niantic.Lightship.Maps.Core;
using Niantic.Lightship.Maps.Core.Features;
using Random = UnityEngine.Random;
using UnityEngine;
namespace MarkTheEarth.MapSystem
{
[PublicAPI]
public class AltarPrefabSpawner : AreaObjectBuilder
{
[SerializeField]
private AltarGameState.StructureType _structureType;
/// <inheritdoc />
public override void Build(IMapTile mapTile, GameObject parent)
{
if (AltarGameState.Instance.IsStructureSpawnEnabled(_structureType))
{
// Only spawn structures if its enabled
base.Build(mapTile, parent);
}
}
/// <inheritdoc />
protected override Quaternion GetObjectRotation(IMapTileFeature feature)
{
// Assign each spawned object a random rotation around the up Vector
return Quaternion.AngleAxis(Random.Range(0.0f, 360.0f), Vector3.up);
}
}
}using System;
using System.Collections.Generic;
using Mono.Cecil;
using Niantic.Lightship.Maps.Core.Coordinates;
using UnityEngine;
using UnityEngine.XR.ARFoundation.VisualScripting;
using static MarkTheEarth.MapSystem.AltarGameState;
namespace MarkTheEarth.MapSystem
{
internal class AltarGameState : MonoBehaviour
{
public static AltarGameState Instance { get; private set; }
private readonly Dictionary<StructureType, int> _structures = new();
private readonly Dictionary<StructureType, bool> _structureSpawningEnabled = new();
[SerializeField] private int _altarsVisited = 0;
[SerializeField] private bool _ritualOngoing = false;
[SerializeField] private bool _ritualFinished = false;
[SerializeField] private int _altarsToVisit;
[SerializeField] private PathRecorder _pathRecorder;
public enum StructureType
{
Altar
}
private void Awake()
{
Instance = this;
Debug.Log("I hath awakened");
_structureSpawningEnabled[StructureType.Altar] = true;
}
public bool IsStructureSpawnEnabled(StructureType structureType)
{
return _structureSpawningEnabled.ContainsKey(structureType) && _structureSpawningEnabled[structureType];
}
public void VisitAltar()
{
_altarsVisited++;
}
public int GetAltarsVisited()
{
return _altarsVisited;
}
public bool IsRitualOngoing()
{
return _ritualOngoing;
}
public bool IsRitualFinished()
{
return _ritualFinished;
}
}
}Now were are going to add an empty GameObject to our MapTheme attach the AltarPrefabSpawner script, decide what structure type it is that we are spawning which for this case we will choose Altar, the map layers and features we want our altars to be spawned on for which i chose the landuse map layer, with the park, recreation area, wood and forest features. Additionally we have to reference the actual prefab that should be spawned for this, I simply built a prefab out of simple 3D-geometry objects and added a world space canvas with a circular image to the base as a way to show progress in the game logic later, that image can be deactivated for now.
For the prototype game to show the path that the player has walked after finishing the ritual we want to track the coordinates of the player during that time and then visualize it using a line renderer.
In unity there exists a regular component called LineRenderer that uses regular position data and through the Maps SDK we have access to the LayerLineRenderer that we can attach to a MapLayer GameObject to render Loops based on Latitude and Longitude coordinates instead. Using Scene coordinates can lead to issues as the map is recentered to the origin if the player object moves to far away from it. That is why we have to use the Map Views function SceneToLatLng() to transform the Players scene position to LatLng coordinates so they are persistent and usable by the 'LayerLineRenderer'. We can also then return the coordinates to the scenes coordinate system through the use of similarly the LatLngToScene() function if you want to use a regular LineRenderer.
To limit the amount of points saved to a manageable number and so we can decide the grade of detail we make use of the Haversine Distance Formula to calculate the distance between two LatLng coordinates in meters and then only record that point if its over a minimum distance that can be set over the editor in a serialized field.
using System.Collections;
using System.Collections.Generic;
using Niantic.Lightship.Maps;
using Niantic.Lightship.Maps.Core;
using Niantic.Lightship.Maps.Core.Coordinates;
using UnityEngine;
public class PathRecorder : MonoBehaviour
{
[SerializeField] private LightshipMapView mapView;
[SerializeField] private Transform player;
[SerializeField] private float minDistanceMeters = 10f; // Minimum distance to record a new point
[SerializeField] private PathVisualizer pathVisualizer;
private List<LatLng> gpsPath = new List<LatLng>();
private bool isRecording = false;
private LatLng lastLatLng;
void Start()
{
if (mapView == null)
{
mapView = FindObjectOfType<LightshipMapView>();
if (mapView == null)
{
Debug.LogError("No LightshipMapView found in the scene!");
return;
}
}
}
void Update()
{
if (Input.GetKeyDown(KeyCode.R)) // Press 'R' to start recording
{
Debug.Log("Record Start Button pressed!");
this.StartRecording();
}
if (Input.GetKeyDown(KeyCode.T)) // Press 'T' to stop recording
{
Debug.Log("Record Stop Button pressed!");
this.StopRecording();
}
if (isRecording && player != null)
{
// Convert player's world position to GPS coordinates
LatLng currentLatLng = mapView.SceneToLatLng(player.position);
// Only record if the player has moved a minimum distance
if (gpsPath.Count == 0 || HaversineDistance(lastLatLng, currentLatLng) > minDistanceMeters)
{
gpsPath.Add(currentLatLng);
lastLatLng = currentLatLng;
}
}
}
// Start recording
public void StartRecording()
{
isRecording = true;
gpsPath.Clear();
Debug.Log("Started recording GPS path.");
}
public void StopRecording()
{
isRecording = false;
Debug.Log($"Stopped recording. Total points: {gpsPath.Count}");
if (pathVisualizer != null)
{
pathVisualizer.DrawPath(gpsPath, mapView);
}
}
public List<LatLng> GetPath()
{
return new List<LatLng>(gpsPath);
}
private double HaversineDistance(LatLng point1, LatLng point2)
{
const double R = 6371000; // Earth radius in meters
double lat1Rad = point1.Latitude * Mathf.Deg2Rad;
double lat2Rad = point2.Latitude * Mathf.Deg2Rad;
double deltaLat = (point2.Latitude - point1.Latitude) * Mathf.Deg2Rad;
double deltaLon = (point2.Longitude - point1.Longitude) * Mathf.Deg2Rad;
double a = Mathf.Sin((float)deltaLat / 2) * Mathf.Sin((float)deltaLat / 2) +
Mathf.Cos((float)lat1Rad) * Mathf.Cos((float)lat2Rad) *
Mathf.Sin((float)deltaLon / 2) * Mathf.Sin((float)deltaLon / 2);
double c = 2 * Mathf.Atan2(Mathf.Sqrt((float)a), Mathf.Sqrt(1 - (float)a));
return R * c; // Distance in meters
}
}using Niantic.Lightship.Maps;
using Niantic.Lightship.Maps.Core.Coordinates;
using Niantic.Lightship.Maps.MapLayers.Components;
using Niantic.Lightship.Maps.ObjectPools;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PathVisualizer : MonoBehaviour
{
[SerializeField]private LineRenderer lineRenderer;
[SerializeField]private LayerLineRenderer layerLineRenderer;
[SerializeField] private float lineStartWidth = 20.0f;
[SerializeField] private float lineEndWidth = 20.0f;
private PooledObject<GameObject> _currentPath; // Store last drawn path
void Start()
{
lineRenderer = GetComponent<LineRenderer>();
// Line style settings
lineRenderer.startWidth = lineStartWidth;
lineRenderer.endWidth = lineEndWidth;
lineRenderer.useWorldSpace = true;
lineRenderer.material = new Material(Shader.Find("Unlit/Color"));
lineRenderer.material.color = Color.red;
}
public void DrawPath(List<LatLng> latLngPath, LightshipMapView mapView)
{
if (latLngPath == null || latLngPath.Count == 0)
{
Debug.LogWarning("No GPS path data to draw.");
return;
}
List<Vector3> scenePoints = new List<Vector3>();
foreach (LatLng latLng in latLngPath)
{
scenePoints.Add(mapView.LatLngToScene(latLng));
}
lineRenderer.positionCount = scenePoints.Count;
lineRenderer.SetPositions(scenePoints.ToArray());
Debug.Log("Path drawn with " + scenePoints.Count + " points.");
if(layerLineRenderer == null)
{
Debug.LogError("PathVisualizer: layerLineRenderer is null!");
return;
}
Debug.Log($"Drawing path with {latLngPath.Count} points.");
// Dispose previous if htere is one
//if(_currentPath.Value != null)
//{
// _currentPath.Dispose();
// Debug.Log("Previous path removed.");
//}
// Draw Layer Renderer path
var pathInstanceName = "PathInstance";
_currentPath = layerLineRenderer.DrawLoop(latLngPath, pathInstanceName);
}
public void ClearPath()
{
lineRenderer.positionCount = 0;
}
}Now to use these scripts we only have to do a few things and those are creating an empty GameObject that we will call LineRenderLayer to which we will attach the Map Layer script and then under that create another empty GameObject to which we will attach the Layer Line Renderer script from the SDK where we can set a material of our choice.
Then we add another empty GameObject into our hierarchy that we can call PathVisualizer to it we will add the script of the same name and a Line Renderer component then we reference both the Layer Line Renderer and Line Renderer component to the script.
Next we can add our Path Recorder script to the Player object and reference the Player transform, Map View and Path Visualizer the script.
Now thanks to the simple Debug Inputs we can test this in the Unity Editor by starting the scene pressing R walking around a little and then pressing T, after this you should see something like this:
For our game prototype we want the player to go and visit a number of our spawned prefabs so that they can complete the ritual and once it is completed, we draw the path that was walked during it and the player could receive an AR scene with a cute eldritch monster as a reward.
For this we are first going to add a new script to the prefab that we are spawning randomly on our map. It needs a reference to our player objects transform, an MeshRenderer that is part of our prefab that we are gonna change the material of, to signify the completion of this altar and two materials one for the uninteracted altar and one for after interaction. Additionally we also reference the image that we attached to the prefab that will act as a sort of progress bar to show the player how long they have to be next to the object:
using System;
using UnityEngine;
namespace MarkTheEarth.MapSystem
{
internal class MapAltarFeature : MonoBehaviour
{
[SerializeField]
private AltarGameState.StructureType _structureType;
public AltarGameState.StructureType StructureType => _structureType;
[SerializeField] private float _radius = 20f; // Distance to start the interaction
[SerializeField] private float _interactionTime = 5f; // Time required to visit the altar
[SerializeField] private Transform player;
[SerializeField] private MeshRenderer _meshRenderer;
[SerializeField] private Material _defaultMaterial;
[SerializeField] private Material _interactedMaterial;
private bool _isCompleted;
private float _timer = 0f;
private bool _isInteracting = false;
private AltarGameState _altarGameState;
// Optional progress bar reference
[SerializeField] private UnityEngine.UI.Image progressBar;
void Start()
{
_altarGameState = AltarGameState.Instance;
if (_meshRenderer != null)
{
_meshRenderer.material = _defaultMaterial;
}
}
private void Awake()
{
player = GameObject.FindGameObjectWithTag("Player").transform;
}
void Update()
{
// Check distance to player
if (_altarGameState != null && player != null && !(_isCompleted) && _altarGameState.IsRitualOngoing())
{
float distance = Vector3.Distance(player.position, transform.position);
if (distance <= _radius)
{
// Player is within range, start or continue the interaction
if (!_isInteracting)
{
StartInteraction();
}
else
{
UpdateInteraction();
}
}
else
{
// Player is out of range, reset
ResetInteraction();
}
}
}
private void StartInteraction()
{
_isInteracting = true;
_timer = 0f;
if (progressBar != null)
{
progressBar.gameObject.SetActive(true);
}
}
private void UpdateInteraction()
{
_timer += Time.deltaTime;
if (progressBar != null)
{
progressBar.fillAmount = _timer / _interactionTime;
}
// If interaction time has passed
if (_timer >= _interactionTime)
{
CompleteInteraction();
}
}
private void ResetInteraction()
{
_isInteracting = false;
_timer = 0f;
if (progressBar != null)
{
progressBar.gameObject.SetActive(false);
}
}
private void CompleteInteraction()
{
_meshRenderer.material = _interactedMaterial;
_isCompleted = true;
// Call VisitAltar when the interaction is complete
_altarGameState.VisitAltar();
Debug.Log("Altar visited!");
// Reset interaction state
ResetInteraction();
}
}
}Lastly we just add two very simple functions to our GameState script to start and end the ritual and we are done:
public void StartRitual()
{
if(_pathRecorder != null)
{
_pathRecorder.StartRecording();
}
_ritualOngoing = true;
}
public void EndRitual()
{
if (_pathRecorder != null)
{
_pathRecorder.StopRecording();
}
_ritualOngoing = false;
}We trigger these methods over buttons that we add to our canvas and the final result looks something like this:
2025-02-16.11-50-43.mp4
Conclusion:
We have created a simple prototype for a Geospatial game mechanic using Niantic Lightship Maps.
Potential Next Steps:
- Add an AR scene as a reward
- Further prettify the game by further customizing the map generation.