railroader-setons-special-s.../src/Modules/Popout/MapViewPresets.cs
seton 9f2097e579 Map: view presets, waypoint pins, and TrackLabelService
Named camera bookmarks, Auto Engineer / WaypointQueue pins, and
extracted track labels. Stock MapWindow stays collapsed while S3
owns the camera. Radio Runner is not in this drop.
2026-09-11 15:19:23 -04:00

347 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using Helpers;
using UI.Map;
using UnityEngine;
namespace S3.Modules.Popout;
internal struct MapViewPreset
{
public string name;
public float x, z, zoom, rotationY;
}
internal static class MapViewPresets
{
private static bool _stashValid;
private static float _stashX, _stashZ, _stashZoom, _stashRot;
public static void PushList(int handle)
{
try
{
var presets = List();
if (presets.Count == 0)
{
Native.RRPOPOUT_SetPresetList(handle, "");
return;
}
var sb = new StringBuilder();
for (int i = 0; i < presets.Count; i++)
{
if (i > 0) sb.Append('\n');
sb.Append(string.IsNullOrEmpty(presets[i].name) ? $"View {i + 1}" : presets[i].name);
}
Native.RRPOPOUT_SetPresetList(handle, sb.ToString());
}
catch (Exception ex)
{
S3.Core.Log.Error($"[popout] preset list push failed: {ex.Message}");
}
}
private static string ReadRename(int handle)
{
try
{
var buf = new StringBuilder(128);
Native.RRPOPOUT_GetPresetRenameName(handle, buf, 128);
return buf.ToString().Trim();
}
catch (Exception ex)
{
S3.Core.Log.Error($"[popout] preset rename read failed: {ex.Message}");
return "";
}
}
public static bool TryHandleCommand(UICmd cmd, float y, int handle, Camera? cam,
Action<float> applyRotation, Action disableFollow)
{
int index = (int)y;
switch (cmd)
{
case UICmd.PresetAdd:
if (cam == null) return true;
AddCurrent(cam);
PushList(handle);
return true;
case UICmd.PresetApply:
if (cam == null) return true;
disableFollow();
Apply(index, cam, applyRotation);
return true;
case UICmd.PresetDelete:
Delete(index);
CancelStash(cam, applyRotation);
PushList(handle);
return true;
case UICmd.PresetRename:
Rename(index, ReadRename(handle));
PushList(handle);
return true;
case UICmd.PresetPreview:
if (cam == null) return true;
disableFollow();
Preview(index, cam, applyRotation);
return true;
case UICmd.PresetCommitEdit:
if (cam == null) return true;
Rename(index, ReadRename(handle));
CommitEdit(index, cam, applyRotation);
PushList(handle);
return true;
case UICmd.PresetCancelEdit:
CancelStash(cam, applyRotation);
return true;
default:
return false;
}
}
private static List<MapViewPreset> List()
{
var s = PopoutModule.Settings;
var names = s.presetNames ?? Array.Empty<string>();
var xs = s.presetX ?? Array.Empty<float>();
var zs = s.presetZ ?? Array.Empty<float>();
var zooms = s.presetZoom ?? Array.Empty<float>();
var rots = s.presetRot ?? Array.Empty<float>();
int n = Math.Min(names.Length, Math.Min(xs.Length, Math.Min(zs.Length, Math.Min(zooms.Length, rots.Length))));
var list = new List<MapViewPreset>(n);
for (int i = 0; i < n; i++)
list.Add(new MapViewPreset
{
name = names[i] ?? $"View {i + 1}",
x = xs[i], z = zs[i], zoom = zooms[i], rotationY = rots[i]
});
return list;
}
private static void Save(List<MapViewPreset> list)
{
int n = list.Count;
var names = new string[n];
var xs = new float[n];
var zs = new float[n];
var zooms = new float[n];
var rots = new float[n];
for (int i = 0; i < n; i++)
{
names[i] = list[i].name ?? "";
xs[i] = list[i].x;
zs[i] = list[i].z;
zooms[i] = list[i].zoom;
rots[i] = list[i].rotationY;
}
var s = PopoutModule.Settings;
s.presetNames = names;
s.presetX = xs;
s.presetZ = zs;
s.presetZoom = zooms;
s.presetRot = rots;
PopoutModule.Persist();
}
private static MapViewPreset Capture(Camera cam)
{
MigrateLegacyIfNeeded(cam);
ToGameXZ(cam.transform.position, out float x, out float z);
return new MapViewPreset
{
name = "",
x = x,
z = z,
zoom = cam.orthographicSize,
rotationY = cam.transform.eulerAngles.y,
};
}
private static void Apply(int index, Camera cam, Action<float> applyRotation)
{
MigrateLegacyIfNeeded(cam);
var presets = List();
if (index < 0 || index >= presets.Count) return;
ApplyPreset(presets[index], cam, applyRotation);
}
private static void ApplyPreset(MapViewPreset p, Camera cam, Action<float> applyRotation)
{
SetCameraXZ(cam, p.x, p.z, PopoutModule.Settings.presetUseGameCoords);
cam.orthographicSize = Mathf.Clamp(p.zoom, 25f, 10000f);
applyRotation(p.rotationY);
PanelFinder.UpdateMapForZoom();
}
// Old presets stored Unity world XZ. After a floating-origin rebase those
// numbers no longer map to the same place on the railroad. Convert the
// whole list the first time we save in game space.
private static void MigrateLegacyIfNeeded(Camera cam)
{
var s = PopoutModule.Settings;
if (s.presetUseGameCoords) return;
var list = List();
for (int i = 0; i < list.Count; i++)
{
var p = list[i];
var world = new Vector3(p.x, cam.transform.position.y, p.z);
ToGameXZ(world, out p.x, out p.z);
list[i] = p;
}
s.presetUseGameCoords = true;
if (list.Count > 0) Save(list);
else PopoutModule.Persist();
}
internal static void ToGameXZ(Vector3 world, out float x, out float z)
{
try
{
var g = WorldTransformer.WorldToGame(world);
x = g.x; z = g.z;
}
catch
{
x = world.x; z = world.z;
}
}
internal static void SetCameraXZ(Camera cam, float x, float z, bool gameSpace)
{
var t = cam.transform;
if (!gameSpace)
{
t.position = new Vector3(x, t.position.y, z);
return;
}
try
{
var world = WorldTransformer.GameToWorld(new Vector3(x, 0f, z));
world.y = t.position.y;
t.position = world;
}
catch
{
t.position = new Vector3(x, t.position.y, z);
}
}
private static void AddCurrent(Camera cam)
{
var list = List();
var p = Capture(cam);
p.name = NextName(list);
list.Add(p);
Save(list);
}
private static string NextName(List<MapViewPreset> list)
{
int n = list.Count + 1;
string name = $"View {n}";
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var p in list)
if (!string.IsNullOrEmpty(p.name)) used.Add(p.name);
while (used.Contains(name))
{
n++;
name = $"View {n}";
}
return name;
}
private static void Delete(int index)
{
var list = List();
if (index < 0 || index >= list.Count) return;
list.RemoveAt(index);
Save(list);
}
private static void Rename(int index, string name)
{
if (string.IsNullOrWhiteSpace(name)) return;
var list = List();
if (index < 0 || index >= list.Count) return;
var p = list[index];
p.name = name.Trim();
list[index] = p;
Save(list);
}
private static void Preview(int index, Camera cam, Action<float> applyRotation)
{
if (!_stashValid)
{
var cur = Capture(cam);
_stashX = cur.x; _stashZ = cur.z; _stashZoom = cur.zoom; _stashRot = cur.rotationY;
_stashValid = true;
}
Apply(index, cam, applyRotation);
}
private static void CommitEdit(int index, Camera cam, Action<float> applyRotation)
{
var list = List();
if (index < 0 || index >= list.Count)
{
CancelStash(cam, applyRotation);
return;
}
var p = Capture(cam);
p.name = list[index].name;
list[index] = p;
Save(list);
RestoreStash(cam, applyRotation);
}
private static void CancelStash(Camera? cam, Action<float> applyRotation)
{
if (cam != null) RestoreStash(cam, applyRotation);
else _stashValid = false;
}
private static void RestoreStash(Camera cam, Action<float> applyRotation)
{
if (!_stashValid) return;
ApplyPreset(new MapViewPreset
{
x = _stashX, z = _stashZ, zoom = _stashZoom, rotationY = _stashRot
}, cam, applyRotation);
_stashValid = false;
}
}
/// <summary>
/// Remembers the map camera between overlay/popout close and the next open.
/// MapEnhancer patches OnWindowShown to snap the camera to the player; we
/// capture before teardown and write the view back after Show() returns.
/// </summary>
internal static class MapCameraMemory
{
public static void Capture(Camera? cam, float rotationY)
{
if (cam == null) return;
var s = PopoutModule.Settings;
MapViewPresets.ToGameXZ(cam.transform.position, out s.lastViewX, out s.lastViewZ);
s.lastViewZoom = cam.orthographicSize;
s.lastViewRot = rotationY;
s.lastViewValid = true;
s.lastViewIsGame = true;
PopoutModule.Persist();
}
public static bool TryRestore(Camera? cam, Action<float> applyRotation)
{
if (cam == null) return false;
var s = PopoutModule.Settings;
if (!s.lastViewValid) return false;
MapViewPresets.SetCameraXZ(cam, s.lastViewX, s.lastViewZ, s.lastViewIsGame);
cam.orthographicSize = Mathf.Clamp(s.lastViewZoom, 25f, 10000f);
applyRotation(s.lastViewRot);
PanelFinder.UpdateMapForZoom();
return true;
}
}