Waypoint Map
M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass.
M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass.
Mod id example.waypointmap, side both.
A code mod: build it with dotnet build (see Making mods) and put the output folder in the game's Mods folder.
mod.json
{
"id": "example.waypointmap",
"name": "Waypoint Map",
"version": "1.1",
"author": "Seeds & Circuits",
"side": "both",
"description": "M opens a map; click for waypoints (Shift+click: shared with everyone). Waypoints show on the compass."
}
WaypointMap.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using SeedsAndCircuits.DebugConsole;
using SeedsAndCircuits.Inventory;
using SeedsAndCircuits.Mods;
using SeedsAndCircuits.Player;
using SeedsAndCircuits.Player.UI;
using SeedsAndCircuits.Sky;
using SeedsAndCircuits.Terrain;
using UnityEngine;
using Compass = SeedsAndCircuits.Player.UI.Compass; // UnityEngine has a Compass too (the device sensor)
using UnityEngine.InputSystem;
namespace WaypointMap
{
/// <summary>
/// M opens a map of the whole world (drawn from the terrain's data, so all of it, loaded or not), centred on you. Click to set a waypoint, Shift+click
/// for a shared one everyone in the game sees, right-click to remove one. Waypoints show on the compass too.
/// <list type="bullet">
/// <item>Private waypoints are this player's own: saved on their machine, per world.</item>
/// <item>Shared waypoints live on the host: players ask the host to add or remove one, the host keeps them in the
/// world save (<c>WorldData</c>) and sends the list to everyone (and to each player who joins).</item>
/// <item>The key is rebindable (Settings ▸ Controls, "Waypoint Map: Open Map"); Settings ▸ Mods has its options.</item>
/// </list>
/// Side "both": everyone in a game needs it, because shared waypoints go through the host's copy.
/// </summary>
public sealed class WaypointMap : Mod
{
[Serializable]
sealed class Waypoint
{
public string name;
public Vector3 position;
public bool shared;
public string by;
}
[Serializable]
sealed class WaypointList
{
public string world;
public List<Waypoint> waypoints = new List<Waypoint>();
}
static readonly Color PrivateColor = new Color(0.45f, 0.75f, 1f, 1f);
static readonly Color SharedColor = new Color(0.45f, 1f, 0.55f, 1f);
const int MapPixels = 1024;
const int RowsPerFrame = 128;
const float MaxZoom = 12f;
const float ClickRadius = 14f;
static WaypointMap s_Instance;
WaypointList _private = new WaypointList();
WaypointList _shared = new WaypointList(); // host: the real list; players: the host's last copy
string _worldKey;
bool _open;
// The whole world's map: drawn once per world, then zoomed and dragged.
Texture2D _map;
Color32[] _pixels;
int _drawnRows;
Rect _area;
float _zoom = 4f;
Vector2 _view = new Vector2(0.5f, 0.5f);
Vector2 _dragStart;
bool _dragged;
GUIStyle _arrow;
GUIStyle _label;
InputAction _openKey;
ModToggle _onCompass;
ModSlider _mapSize;
// ------------------------------------------------------------------ lifecycle
public override void OnLoad()
{
s_Instance = this;
_openKey = AddKey("Open Map", "<Keyboard>/m");
_onCompass = AddToggle("compass", "Waypoints on the compass", true);
_mapSize = AddSlider("size", "Map size (% of the screen)", 50f, 95f, 85f, 5f);
// Host: players ask for changes to the shared list.
OnMessage("add", msg =>
{
if (!IsServer)
return;
Waypoint w = Json.FromJson<Waypoint>(msg.Text);
if (w == null || string.IsNullOrWhiteSpace(w.name))
return;
w.shared = true;
w.name = w.name.Trim();
_shared.waypoints.RemoveAll(x => x.name == w.name);
_shared.waypoints.Add(w);
SaveShared();
SendShared();
});
OnMessage("remove", msg =>
{
if (!IsServer || _shared.waypoints.RemoveAll(x => x.name == msg.Text) == 0)
return;
SaveShared();
SendShared();
});
// Everyone (the host too): the host's shared list, and which world it is.
OnMessage("shared", msg =>
{
WaypointList list = Json.FromJson<WaypointList>(msg.Text);
if (list == null)
return;
_shared = list;
if (_worldKey != list.world)
{
_worldKey = list.world;
LoadPrivate();
}
});
Compass.CollectMarkers += AddCompassMarkers;
}
public override void OnUnload()
{
Compass.CollectMarkers -= AddCompassMarkers;
Close();
ResetMap();
s_Instance = null;
}
public override void OnWorldLoaded()
{
if (!IsServer)
return; // players get the world and list from the host when they join
// The host's world: its name plus the host's player id, so two hosts' "World 1"s don't mix.
_worldKey = $"{WorldName}-{Short(InventoryStore.LocalPlayerId)}";
_shared = WorldData.Get<WaypointList>("shared") ?? new WaypointList();
_shared.world = _worldKey;
LoadPrivate();
SendShared();
}
public override void OnWorldUnloaded()
{
Close();
ResetMap();
_shared = new WaypointList();
_private = new WaypointList();
_worldKey = null;
}
public override void OnPlayerJoined(ulong clientId) =>
SendToClient(clientId, "shared", Json.ToJson(_shared));
// ------------------------------------------------------------------ files
static string Short(string id) => string.IsNullOrEmpty(id) ? "host" : id.Length > 8 ? id.Substring(0, 8) : id;
static string Safe(string name) => string.Concat((name ?? "world").Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
string PrivatePath => Path.Combine(DataFolder, "private", Safe(_worldKey) + ".json");
WaypointList Load(string path)
{
try
{
return File.Exists(path) ? Json.FromJson<WaypointList>(File.ReadAllText(path)) : null;
}
catch (Exception e)
{
LogWarning($"Could not read {path}: {e.Message}");
return null;
}
}
void Save(string path, WaypointList list)
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, Json.ToJson(list, pretty: true));
}
void LoadPrivate() => _private = (_worldKey != null ? Load(PrivatePath) : null) ?? new WaypointList { world = _worldKey };
void SavePrivate()
{
if (_worldKey != null)
Save(PrivatePath, _private);
}
// In the world save: it moves, copies and gets deleted with the world.
void SaveShared() => WorldData.Set("shared", _shared);
void SendShared() => SendToClients("shared", Json.ToJson(_shared));
// ------------------------------------------------------------------ waypoints
IEnumerable<Waypoint> All => _private.waypoints.Concat(_shared.waypoints);
string NextName()
{
for (int i = 1; ; i++)
{
string name = $"Waypoint {i}";
if (All.All(w => w.name != name))
return name;
}
}
void Add(string name, Vector3 position, bool shared)
{
var w = new Waypoint { name = name, position = position, shared = shared, by = PlayerSpawn.Local != null ? PlayerSpawn.Local.DisplayName : null };
if (shared)
{
SendToServer("add", Json.ToJson(w)); // the host adds it and sends everyone the new list
return;
}
_private.waypoints.RemoveAll(x => x.name == name);
_private.waypoints.Add(w);
SavePrivate();
}
bool Remove(string name)
{
if (_private.waypoints.RemoveAll(x => x.name == name) > 0)
{
SavePrivate();
return true;
}
if (_shared.waypoints.Any(x => x.name == name))
{
SendToServer("remove", name);
return true;
}
return false;
}
void AddCompassMarkers(List<Compass.ExtraMarker> markers)
{
if (!_onCompass.Value)
return;
foreach (Waypoint w in All)
markers.Add(new Compass.ExtraMarker(w.position, w.shared ? SharedColor : PrivateColor));
}
// ------------------------------------------------------------------ map
public override void OnUpdate()
{
DrawMapSlice();
if (PlayerSpawn.Local == null || DebugConsole.IsOpen)
return;
// Mod keys only work during play; with the map open the game's controls are off, so the same key is read
// straight from the keyboard to close it.
if (!_open && _openKey.WasPressedThisFrame())
Open();
else if (_open && PressedNow(_openKey))
Close();
}
/// <summary>Escape closes the map (instead of opening the pause menu).</summary>
public override void OnCloseScreen() => Close();
static bool PressedNow(InputAction action)
{
foreach (InputBinding binding in action.bindings)
{
if (InputSystem.FindControl(binding.effectivePath) is UnityEngine.InputSystem.Controls.ButtonControl button && button.wasPressedThisFrame)
return true;
}
return false;
}
void Open()
{
_open = true;
// Frees the cursor and turns the game's controls off (moving, tools, hotbar scroll...) until closed.
ScreenOpen = true;
// Centred on you.
if (PlayerSpawn.Local != null && _area.width > 0f)
_view = ToMap(PlayerSpawn.Local.Feet);
}
void Close()
{
if (!_open)
return;
_open = false;
ScreenOpen = false;
}
/// <summary>Photographs the area around the player from straight above, north up.</summary>
// ------------------------------------------------------------------ the map picture
/// <summary>
/// Draws the whole world into the map texture a few rows per frame after the world loads, from the terrain's
/// data (TerrainWorld.RenderMap), so it's ready by the time the map opens and costs no frame much.
/// </summary>
void DrawMapSlice()
{
TerrainWorld world = TerrainWorld.Instance;
if (world == null || !world.IsInitialized || PlayerSpawn.Local == null)
return;
if (_pixels == null)
{
_area = world.MapArea(PlayerSpawn.Local.Feet, 2048f);
_pixels = new Color32[MapPixels * MapPixels];
_drawnRows = 0;
}
if (_drawnRows >= MapPixels)
return;
if (!world.RenderMap(_area, MapPixels, _pixels, _drawnRows, RowsPerFrame))
return;
_drawnRows = Mathf.Min(MapPixels, _drawnRows + RowsPerFrame);
if (_drawnRows < MapPixels && !_open)
return;
// Show progress while the map is open; upload once at the end otherwise.
if (_map == null)
_map = new Texture2D(MapPixels, MapPixels, TextureFormat.RGBA32, true) { name = "Waypoint Map", wrapMode = TextureWrapMode.Clamp };
_map.SetPixels32(_pixels);
_map.Apply();
}
void ResetMap()
{
_pixels = null;
_drawnRows = 0;
if (_map != null)
UnityEngine.Object.Destroy(_map);
_map = null;
}
/// <summary>World position → 0..1 across the whole map (x east, y north).</summary>
Vector2 ToMap(Vector3 world) => new Vector2((world.x - _area.xMin) / _area.width, (world.z - _area.yMin) / _area.height);
Vector3 ToWorld(Vector2 map)
{
float x = _area.xMin + map.x * _area.width;
float z = _area.yMin + map.y * _area.height;
Vector3 me = PlayerSpawn.Local != null ? PlayerSpawn.Local.Feet : Vector3.zero;
// The ground under the click where it's loaded; elsewhere the player's height will do for a compass mark.
return Physics.Raycast(new Vector3(x, me.y + 500f, z), Vector3.down, out RaycastHit hit, 1500f, ~0, QueryTriggerInteraction.Ignore)
? hit.point
: new Vector3(x, me.y, z);
}
// ------------------------------------------------------------------ the map screen
public override void OnGUI()
{
if (!_open)
return;
_label ??= new GUIStyle(GUI.skin.label) { fontSize = 14, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
float size = Mathf.Min(Screen.width, Screen.height) * _mapSize.Value / 100f;
var rect = new Rect((Screen.width - size) * 0.5f, (Screen.height - size) * 0.5f, size, size);
GUI.color = new Color(0f, 0f, 0f, 0.6f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
GUI.color = new Color(0.08f, 0.1f, 0.12f);
GUI.DrawTexture(rect, Texture2D.whiteTexture);
GUI.color = Color.white;
// The visible part of the world map: 1 / zoom of it, around _view (0..1).
float span = 1f / _zoom;
_view.x = Mathf.Clamp(_view.x, span * 0.5f, 1f - span * 0.5f);
_view.y = Mathf.Clamp(_view.y, span * 0.5f, 1f - span * 0.5f);
var uv = new Rect(_view.x - span * 0.5f, _view.y - span * 0.5f, span, span);
if (_map != null)
GUI.DrawTextureWithTexCoords(rect, _map, uv);
if (_drawnRows < MapPixels)
GUI.Label(new Rect(rect.x + 10, rect.y + 10, 300, 24), $"Drawing the map... {100 * _drawnRows / MapPixels}%", _label);
Vector2 ToScreen(Vector3 world)
{
Vector2 m = ToMap(world);
return new Vector2(rect.x + (m.x - uv.x) / uv.width * rect.width, rect.y + (1f - (m.y - uv.y) / uv.height) * rect.height);
}
GUI.BeginGroup(rect);
var local = new Rect(0, 0, rect.width, rect.height);
Vector2 Local(Vector3 world) => ToScreen(world) - rect.position;
foreach (PlayerSpawn p in PlayerSpawn.All)
{
if (p != null && p != PlayerSpawn.Local)
Dot(Local(p.Feet), new Color(0.55f, 0.85f, 1f), p.DisplayName);
}
foreach (Waypoint w in All)
Dot(Local(w.position), w.shared ? SharedColor : PrivateColor, w.name + (w.shared && !string.IsNullOrEmpty(w.by) ? $" ({w.by})" : string.Empty));
PlayerSpawn me = PlayerSpawn.Local;
if (me != null)
Arrow(Local(me.Feet), Camera.main != null ? Camera.main.transform.eulerAngles.y : me.transform.eulerAngles.y);
GUI.EndGroup();
GUI.color = Color.white;
GUI.Label(new Rect(rect.x, rect.yMax + 4, rect.width, 24),
$"Click: waypoint · Shift+click: shared · Right-click: remove · Drag: move · Scroll: zoom · {_openKey.GetBindingDisplayString()}: close", _label);
Event e = Event.current;
if (!rect.Contains(e.mousePosition) && e.type != EventType.MouseUp && e.type != EventType.MouseDrag)
return;
switch (e.type)
{
case EventType.ScrollWheel:
{
// Zoom around the point under the mouse.
Vector2 m = new Vector2(uv.x + (e.mousePosition.x - rect.x) / rect.width * uv.width,
uv.y + (1f - (e.mousePosition.y - rect.y) / rect.height) * uv.height);
float zoom = Mathf.Clamp(_zoom * (e.delta.y > 0 ? 0.8f : 1.25f), 1f, MaxZoom);
_view = m + (_view - m) * (_zoom / zoom);
_zoom = zoom;
e.Use();
break;
}
case EventType.MouseDown when e.button == 0:
_dragStart = e.mousePosition;
_dragged = false;
e.Use();
break;
case EventType.MouseDrag when e.button == 0:
if ((e.mousePosition - _dragStart).sqrMagnitude > 16f)
_dragged = true;
_view -= new Vector2(e.delta.x / rect.width * uv.width, -e.delta.y / rect.height * uv.height);
e.Use();
break;
case EventType.MouseUp when e.button == 0:
if (!_dragged && rect.Contains(e.mousePosition))
{
var m = new Vector2(uv.x + (e.mousePosition.x - rect.x) / rect.width * uv.width,
uv.y + (1f - (e.mousePosition.y - rect.y) / rect.height) * uv.height);
Add(NextName(), ToWorld(m), shared: e.shift);
}
e.Use();
break;
case EventType.MouseDown when e.button == 1:
{
Waypoint near = All.OrderBy(w => (ToScreen(w.position) - e.mousePosition).sqrMagnitude).FirstOrDefault();
if (near != null && (ToScreen(near.position) - e.mousePosition).magnitude <= ClickRadius)
Remove(near.name);
e.Use();
break;
}
}
}
void Dot(Vector2 at, Color color, string label)
{
GUI.color = Color.black;
GUI.DrawTexture(new Rect(at.x - 6, at.y - 6, 12, 12), Texture2D.whiteTexture);
GUI.color = color;
GUI.DrawTexture(new Rect(at.x - 4, at.y - 4, 8, 8), Texture2D.whiteTexture);
if (string.IsNullOrEmpty(label))
return;
GUI.color = Color.black;
GUI.Label(new Rect(at.x + 9, at.y - 11, 300, 22), label, _label);
GUI.color = color;
GUI.Label(new Rect(at.x + 8, at.y - 12, 300, 22), label, _label);
}
/// <summary>You: a white arrow pointing the way you look (north up).</summary>
void Arrow(Vector2 at, float yaw)
{
Matrix4x4 before = GUI.matrix;
GUIUtility.RotateAroundPivot(yaw, at);
GUI.color = Color.black;
GUI.Label(new Rect(at.x - 13, at.y - 15, 30, 30), "▲", _arrow ??= new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleCenter });
GUI.color = Color.white;
GUI.Label(new Rect(at.x - 15, at.y - 16, 30, 30), "▲", _arrow);
GUI.matrix = before;
}
// ------------------------------------------------------------------ console
[ConsoleCommandAttribute("waypoint.add", "A waypoint at your feet (add 'shared' for everyone).", Usage = "<name> [shared]", Local = true)]
static string AddCommand(ConsoleArgs args)
{
WaypointMap mod = s_Instance;
PlayerSpawn local = PlayerSpawn.Local;
if (mod == null || local == null)
return "Not in a world.";
bool shared = args.Count > 1 && args[args.Count - 1] == "shared";
string name = args.Count == 0 ? mod.NextName() : string.Join(" ", Enumerable.Range(0, shared ? args.Count - 1 : args.Count).Select(i => args[i]));
mod.Add(name, local.Feet, shared);
return $"Waypoint \"{name}\"{(shared ? " shared" : string.Empty)}.";
}
[ConsoleCommandAttribute("waypoint.remove", "Removes a waypoint.", Usage = "<name>", Local = true)]
static string RemoveCommand(ConsoleArgs args)
{
WaypointMap mod = s_Instance;
string name = args.Rest(0) ?? throw new ConsoleArgumentException("Remove which?");
return mod != null && mod.Remove(name) ? $"Removed \"{name}\"." : $"No waypoint \"{name}\".";
}
[ConsoleCommandAttribute("waypoint.list", "Your waypoints and the shared ones.", Local = true)]
static string ListCommand()
{
WaypointMap mod = s_Instance;
if (mod == null || !mod.All.Any())
return "No waypoints.";
Vector3 me = PlayerSpawn.Local != null ? PlayerSpawn.Local.Feet : Vector3.zero;
return string.Join("\n", mod.All.Select(w =>
$"{(w.shared ? "[shared] " : string.Empty)}{w.name}: {Vector3.Distance(me, w.position):0} m{(w.shared && !string.IsNullOrEmpty(w.by) ? $" (by {w.by})" : string.Empty)}"));
}
}
}
WaypointMap.csproj
<Project Sdk="Microsoft.NET.Sdk">
<!-- Build settings and game references: ../Mod.props. Output: bin/Waypoint Map/ -->
<PropertyGroup>
<AssemblyName>WaypointMap</AssemblyName>
<ModFolder>Waypoint Map</ModFolder>
</PropertyGroup>
<Import Project="../Mod.props" />
</Project>