diff --git a/Directory.Build.props b/Directory.Build.props
index 947f80c..6ade7a6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,7 +11,7 @@
It should follow the format major.minor.patch (semantic versioning). If you publish your mod
as a library to NuGet, this version will also be used as the package version.
-->
- 0.4.0
+ 1.0.0
$(DefaultItemExcludes);obj\**;obj\ss\**;obj\hk\**
diff --git a/src/General/ApiJson.cs b/src/General/ApiJson.cs
new file mode 100644
index 0000000..4b11c1a
--- /dev/null
+++ b/src/General/ApiJson.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace ReplayTimerMod
+{
+ ///
+ /// Lightweight JSON serialization for API payloads.
+ /// Uses the same approach as the existing MiniJson (StringBuilder for
+ /// writing, hand-rolled recursive descent for reading) but handles
+ /// network-specific types instead of DataStore types.
+ ///
+ /// Does NOT modify or depend on MiniJson. Clean separation.
+ ///
+ internal static class ApiJson
+ {
+ // ── Serialization ──────────────────────────────────────────────────
+
+ public static string SerializeUpload(UploadPayload p)
+ {
+ var sb = new StringBuilder(p.ReplayData.Length + 512);
+ sb.Append('{');
+ AppendKV(sb, "game", p.Game, first: true);
+ AppendKV(sb, "scene_name", p.SceneName);
+ AppendKV(sb, "entry_from", p.EntryFrom);
+ AppendKV(sb, "exit_to", p.ExitTo);
+ AppendKVFloat(sb, "total_time", p.TotalTime);
+ AppendKVInt(sb, "frame_count", p.FrameCount);
+ AppendKV(sb, "captured_at",
+ new DateTime(p.CapturedAtUtcTicks, DateTimeKind.Utc)
+ .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
+ AppendKV(sb, "replay_data", p.ReplayData);
+ sb.Append('}');
+ return sb.ToString();
+ }
+
+ // ── Deserialization ────────────────────────────────────────────────
+
+ public static UploadResponse ParseUploadResponse(string json)
+ {
+ var r = new UploadResponse { Rank = -1, TotalRunners = -1 };
+ var fields = ParseFlat(json);
+
+ if (fields.TryGetValue("run_id", out var rid))
+ r.RunId = rid;
+ if (fields.TryGetValue("rank", out var rank))
+ r.Rank = ParseInt(rank, -1);
+ if (fields.TryGetValue("total_runners", out var tr))
+ r.TotalRunners = ParseInt(tr, -1);
+ if (fields.TryGetValue("is_pb", out var pb))
+ r.IsPB = pb == "true";
+ if (fields.TryGetValue("display_name", out var dn))
+ r.DisplayName = dn;
+
+ return r;
+ }
+
+ public static ConfigResponse ParseConfigResponse(string json)
+ {
+ var r = new ConfigResponse();
+ var fields = ParseFlat(json);
+
+ if (fields.TryGetValue("min_mod_version", out var mv))
+ r.MinModVersion = mv;
+ if (fields.TryGetValue("maintenance", out var m))
+ r.Maintenance = m == "true";
+ if (fields.TryGetValue("announcement", out var a) && a != null)
+ r.Announcement = a;
+
+ return r;
+ }
+
+ // ── StringBuilder helpers ──────────────────────────────────────────
+
+ private static void AppendKV(StringBuilder sb, string key, string value,
+ bool first = false)
+ {
+ if (!first) sb.Append(',');
+ sb.Append('"');
+ sb.Append(key);
+ sb.Append("\":");
+ AppendString(sb, value);
+ }
+
+ private static void AppendKVFloat(StringBuilder sb, string key, float value)
+ {
+ sb.Append(",\"");
+ sb.Append(key);
+ sb.Append("\":");
+ sb.Append(value.ToString("R", CultureInfo.InvariantCulture));
+ }
+
+ private static void AppendKVInt(StringBuilder sb, string key, int value)
+ {
+ sb.Append(",\"");
+ sb.Append(key);
+ sb.Append("\":");
+ sb.Append(value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ private static void AppendString(StringBuilder sb, string s)
+ {
+ if (s == null) { sb.Append("null"); return; }
+
+ sb.Append('"');
+ foreach (char c in s)
+ {
+ switch (c)
+ {
+ case '"': sb.Append("\\\""); break;
+ case '\\': sb.Append("\\\\"); break;
+ case '\n': sb.Append("\\n"); break;
+ case '\r': sb.Append("\\r"); break;
+ case '\t': sb.Append("\\t"); break;
+ default:
+ if (c < 0x20)
+ sb.Append("\\u").Append(((int)c).ToString("x4"));
+ else
+ sb.Append(c);
+ break;
+ }
+ }
+ sb.Append('"');
+ }
+
+ // ── Flat JSON parser ───────────────────────────────────────────────
+ //
+ // Parses a single-level JSON object into a Dictionary.
+ // String values are unescaped. Numbers and booleans are returned as
+ // their literal text. Nested objects/arrays are skipped. Null values
+ // map to the string "null".
+ //
+ // This is intentionally simple — API responses are flat objects.
+
+ private static Dictionary ParseFlat(string json)
+ {
+ var result = new Dictionary();
+ if (string.IsNullOrEmpty(json)) return result;
+
+ int i = 0;
+ SkipWs(json, ref i);
+ if (i >= json.Length || json[i] != '{') return result;
+ i++; // skip '{'
+
+ while (i < json.Length)
+ {
+ SkipWs(json, ref i);
+ if (i >= json.Length) break;
+ if (json[i] == '}') break;
+ if (json[i] == ',') { i++; continue; }
+
+ string key = ReadString(json, ref i);
+ SkipWs(json, ref i);
+ if (i >= json.Length || json[i] != ':') break;
+ i++; // skip ':'
+ SkipWs(json, ref i);
+
+ if (i >= json.Length) break;
+
+ char c = json[i];
+ if (c == '"')
+ {
+ result[key] = ReadString(json, ref i);
+ }
+ else if (c == 'n' && i + 3 < json.Length
+ && json[i + 1] == 'u' && json[i + 2] == 'l' && json[i + 3] == 'l')
+ {
+ result[key] = null;
+ i += 4;
+ }
+ else if (c == 't' && i + 3 < json.Length
+ && json[i + 1] == 'r' && json[i + 2] == 'u' && json[i + 3] == 'e')
+ {
+ result[key] = "true";
+ i += 4;
+ }
+ else if (c == 'f' && i + 4 < json.Length
+ && json[i + 1] == 'a' && json[i + 2] == 'l'
+ && json[i + 3] == 's' && json[i + 4] == 'e')
+ {
+ result[key] = "false";
+ i += 5;
+ }
+ else if (c == '{' || c == '[')
+ {
+ SkipNested(json, ref i);
+ }
+ else
+ {
+ // Number or other literal
+ int start = i;
+ while (i < json.Length && json[i] != ',' && json[i] != '}'
+ && json[i] != ' ' && json[i] != '\n' && json[i] != '\r'
+ && json[i] != '\t')
+ i++;
+ result[key] = json.Substring(start, i - start);
+ }
+ }
+
+ return result;
+ }
+
+ private static string ReadString(string json, ref int i)
+ {
+ if (i >= json.Length || json[i] != '"')
+ return "";
+ i++; // skip opening quote
+
+ var sb = new StringBuilder();
+ while (i < json.Length)
+ {
+ char c = json[i];
+ if (c == '"') { i++; break; }
+ if (c == '\\' && i + 1 < json.Length)
+ {
+ i++;
+ switch (json[i])
+ {
+ case '"': sb.Append('"'); break;
+ case '\\': sb.Append('\\'); break;
+ case '/': sb.Append('/'); break;
+ case 'n': sb.Append('\n'); break;
+ case 'r': sb.Append('\r'); break;
+ case 't': sb.Append('\t'); break;
+ case 'u':
+ if (i + 4 < json.Length)
+ {
+ string hex = json.Substring(i + 1, 4);
+ sb.Append((char)Convert.ToInt32(hex, 16));
+ i += 4;
+ }
+ break;
+ default: sb.Append(json[i]); break;
+ }
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ i++;
+ }
+ return sb.ToString();
+ }
+
+ private static void SkipWs(string json, ref int i)
+ {
+ while (i < json.Length && (json[i] == ' ' || json[i] == '\t'
+ || json[i] == '\n' || json[i] == '\r'))
+ i++;
+ }
+
+ private static void SkipNested(string json, ref int i)
+ {
+ char open = json[i];
+ char close = open == '{' ? '}' : ']';
+ int depth = 1;
+ i++;
+ bool inStr = false;
+ while (i < json.Length && depth > 0)
+ {
+ char c = json[i];
+ if (inStr)
+ {
+ if (c == '\\') { i++; } // skip escaped char
+ else if (c == '"') { inStr = false; }
+ }
+ else
+ {
+ if (c == '"') inStr = true;
+ else if (c == open) depth++;
+ else if (c == close) depth--;
+ }
+ i++;
+ }
+ }
+
+ private static int ParseInt(string s, int fallback)
+ {
+ if (string.IsNullOrEmpty(s)) return fallback;
+ int result;
+ return int.TryParse(s, NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out result) ? result : fallback;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/General/ApiTypes.cs b/src/General/ApiTypes.cs
new file mode 100644
index 0000000..6c4a86a
--- /dev/null
+++ b/src/General/ApiTypes.cs
@@ -0,0 +1,74 @@
+namespace ReplayTimerMod
+{
+ ///
+ /// Queued payload for background upload. Captures everything needed
+ /// to POST a run without holding a reference to the full ReplaySnapshot
+ /// (which contains the decoded FrameData[] and should not be pinned
+ /// longer than necessary).
+ ///
+ internal sealed class UploadPayload
+ {
+ public string SnapshotId;
+ public string Game;
+ public string SceneName;
+ public string EntryFrom;
+ public string ExitTo;
+ public float TotalTime;
+ public int FrameCount;
+ public long CapturedAtUtcTicks;
+ public string ReplayData; // base64 RTM3 string (already encoded)
+ public string ModVersion;
+
+ // Retry state (managed by UploadWorker)
+ public int RetryCount;
+ public long RetryAfterTicks; // DateTime.UtcNow.Ticks when eligible
+ }
+
+ ///
+ /// Parsed response from POST /api/v1/runs.
+ ///
+ internal sealed class UploadResponse
+ {
+ public string RunId;
+ public int Rank; // -1 if not returned
+ public int TotalRunners; // -1 if not returned
+ public bool IsPB;
+ public string DisplayName; // server-assigned name (first upload)
+
+ public bool HasRank => Rank > 0 && TotalRunners > 0;
+ }
+
+ ///
+ /// Parsed response from GET /api/v1/config.
+ ///
+ internal sealed class ConfigResponse
+ {
+ public string MinModVersion;
+ public bool Maintenance;
+ public string Announcement; // null if none
+ }
+
+ ///
+ /// Rank info dispatched to the main thread after a successful upload.
+ ///
+ public sealed class RankInfo
+ {
+ public readonly string SceneName;
+ public readonly string EntryFrom;
+ public readonly string ExitTo;
+ public readonly float TotalTime;
+ public readonly int Rank;
+ public readonly int TotalRunners;
+
+ public RankInfo(string sceneName, string entryFrom, string exitTo,
+ float totalTime, int rank, int totalRunners)
+ {
+ SceneName = sceneName;
+ EntryFrom = entryFrom;
+ ExitTo = exitTo;
+ TotalTime = totalTime;
+ Rank = rank;
+ TotalRunners = totalRunners;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/General/GhostSettings.cs b/src/General/GhostSettings.cs
index 9545b68..52b0697 100644
--- a/src/General/GhostSettings.cs
+++ b/src/General/GhostSettings.cs
@@ -15,6 +15,10 @@ public sealed class GhostSettingsData
public bool SaveAllRunsEnabled = false;
public int MaxSavedReplaysPerRoute = 5;
public bool TimerHudEnabled = true;
+ public bool OnlineEnabled = false;
+ public string DeviceId = "";
+ public string DisplayName = "";
+ public string ApiBaseUrl = "https://oqsfhqbakarleqahxiyo.supabase.co/functions/v1";
}
public static class GhostSettings
@@ -72,6 +76,41 @@ public static bool TimerHudEnabled
set { _d.TimerHudEnabled = value; Save(); }
}
+ public static bool OnlineEnabled
+ {
+ get => _d.OnlineEnabled;
+ set { _d.OnlineEnabled = value; Save(); }
+ }
+
+ public static string DeviceId
+ {
+ get => _d.DeviceId;
+ set { _d.DeviceId = value; Save(); }
+ }
+
+ public static string DisplayName
+ {
+ get => _d.DisplayName;
+ set { _d.DisplayName = value; Save(); }
+ }
+
+ public static string ApiBaseUrl
+ {
+ get => _d.ApiBaseUrl;
+ set { _d.ApiBaseUrl = value; Save(); }
+ }
+
+ ///
+ /// Ensures a device ID exists, generating one if needed.
+ /// Called when online features are first enabled.
+ ///
+ public static void EnsureDeviceId()
+ {
+ if (!string.IsNullOrEmpty(_d.DeviceId)) return;
+ _d.DeviceId = System.Guid.NewGuid().ToString("N");
+ Save();
+ }
+
// ── Init ─────────────────────────────────────────────────────────────
public static void Init(string baseDirectory)
@@ -130,9 +169,10 @@ private static object ParseField(System.Type t, string val, object fallback)
{
try
{
- if (t == typeof(bool)) return bool.Parse(val);
- if (t == typeof(int)) return int.Parse(val, System.Globalization.CultureInfo.InvariantCulture);
- if (t == typeof(float)) return float.Parse(val, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture);
+ if (t == typeof(bool)) return bool.Parse(val);
+ if (t == typeof(int)) return int.Parse(val, System.Globalization.CultureInfo.InvariantCulture);
+ if (t == typeof(float)) return float.Parse(val, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture);
+ if (t == typeof(string)) return val;
}
catch { }
return fallback;
diff --git a/src/General/NetworkClient.cs b/src/General/NetworkClient.cs
new file mode 100644
index 0000000..024560e
--- /dev/null
+++ b/src/General/NetworkClient.cs
@@ -0,0 +1,302 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Threading;
+using BepInEx.Logging;
+
+namespace ReplayTimerMod
+{
+ ///
+ /// Central networking class for the replay mod.
+ ///
+ /// Lifecycle:
+ /// Created in mod entry point (Initialize/Awake)
+ /// Start() called after Setup (hero ready)
+ /// Tick() called every frame from the mod's update loop
+ /// Stop() called on mod teardown / OnDestroy
+ ///
+ /// Threading:
+ /// All public methods are called from the Unity main thread.
+ /// Background work (uploads, config fetch) runs on worker threads.
+ /// Results are dispatched back to the main thread via _mainCallbacks.
+ ///
+ /// Compatibility:
+ /// Compiles on net35. Uses only ThreadPool, lock, Queue, HttpWebRequest,
+ /// ManualResetEvent. No Task, no async/await, no ConcurrentQueue.
+ ///
+ public sealed class NetworkClient
+ {
+ private static readonly ManualLogSource Log =
+ BepInEx.Logging.Logger.CreateLogSource("NetworkClient");
+
+ private const int MaxCallbacksPerTick = 4;
+ private const int HttpTimeoutMs = 10000;
+
+ // ── Immutable config ───────────────────────────────────────────────
+
+ private readonly string _deviceId;
+ private readonly string _gameTag; // "hk_1221", "hk_1578", "silksong"
+ private readonly string _modVersion;
+ private readonly string _apiBaseUrl;
+
+ // ── Main-thread callback queue ─────────────────────────────────────
+
+ private readonly object _callbackLock = new object();
+ private readonly Queue _mainCallbacks = new Queue();
+
+ // ── Sub-components ─────────────────────────────────────────────────
+
+ private UploadWorker _uploadWorker;
+ private bool _started;
+
+ // ── State ──────────────────────────────────────────────────────────
+
+ private ConfigResponse _serverConfig;
+ private bool _configFetched;
+ private bool _maintenanceMode;
+
+ // ── Events (fired on main thread) ──────────────────────────────────
+
+ ///
+ /// Fired after a successful upload returns a rank.
+ /// Consumed by RoomTimerHUD to display rank overlay.
+ ///
+ public event Action OnRankReceived;
+
+ ///
+ /// Fired when the server assigns or updates the display name.
+ /// Consumed by the mod entry point to persist in settings.
+ ///
+ public event Action OnDisplayNameReceived;
+
+ // ── Construction ───────────────────────────────────────────────────
+
+ public NetworkClient(string deviceId, string gameTag,
+ string modVersion, string apiBaseUrl)
+ {
+ _deviceId = deviceId;
+ _gameTag = gameTag;
+ _modVersion = modVersion;
+ _apiBaseUrl = TrimTrailingSlash(apiBaseUrl);
+
+ Log.LogInfo($"[NetworkClient] Initialized: game={_gameTag} " +
+ $"device={_deviceId.Substring(0, 8)}... " +
+ $"api={_apiBaseUrl}");
+ }
+
+ // ── Lifecycle ──────────────────────────────────────────────────────
+
+ ///
+ /// Starts the upload worker and fetches server config.
+ /// Called once, after the hero is ready and UI is set up.
+ ///
+ public void Start()
+ {
+ if (_started) return;
+ _started = true;
+
+ _uploadWorker = new UploadWorker(_apiBaseUrl, _deviceId, PostToMain);
+ _uploadWorker.OnUploadSuccess += HandleUploadSuccess;
+ _uploadWorker.OnDisplayNameReceived += HandleDisplayNameReceived;
+ _uploadWorker.Start();
+
+ // Fetch server config on a background thread
+ ThreadPool.QueueUserWorkItem(_ => FetchConfig());
+ }
+
+ ///
+ /// Shuts down the upload worker and clears pending callbacks.
+ ///
+ public void Stop()
+ {
+ if (!_started) return;
+
+ if (_uploadWorker != null)
+ {
+ _uploadWorker.OnUploadSuccess -= HandleUploadSuccess;
+ _uploadWorker.OnDisplayNameReceived -= HandleDisplayNameReceived;
+ _uploadWorker.Stop();
+ _uploadWorker = null;
+ }
+
+ lock (_callbackLock)
+ {
+ _mainCallbacks.Clear();
+ }
+
+ _started = false;
+ Log.LogInfo("[NetworkClient] Stopped");
+ }
+
+ ///
+ /// Drains the main-thread callback queue. Call every frame from the
+ /// mod's update loop (alongside replayUI.Tick() etc).
+ ///
+ public void Tick()
+ {
+ if (!_started) return;
+
+ int budget = MaxCallbacksPerTick;
+ while (budget > 0)
+ {
+ Action action = null;
+ lock (_callbackLock)
+ {
+ if (_mainCallbacks.Count > 0)
+ action = _mainCallbacks.Dequeue();
+ }
+ if (action == null) break;
+
+ try
+ {
+ action();
+ }
+ catch (Exception ex)
+ {
+ Log.LogError($"[NetworkClient] Callback error: {ex.Message}");
+ }
+ budget--;
+ }
+ }
+
+ // ── Upload API ─────────────────────────────────────────────────────
+
+ ///
+ /// Enqueues a completed run for background upload.
+ /// Called from the mod entry point after PBManager.Evaluate().
+ ///
+ /// Only uploads new PBs and first runs. Missed PBs, duplicates,
+ /// and history saves are NOT uploaded.
+ ///
+ /// Cost on the critical path: one object allocation + one lock +
+ /// one ManualResetEvent.Set(). Total: sub-microsecond.
+ ///
+ public void EnqueueUpload(ReplaySnapshot snapshot, EvaluationResult result)
+ {
+ if (!_started) return;
+ if (_maintenanceMode) return;
+
+ // Only upload PBs and first runs
+ if (result.Kind != ResultKind.FirstRun
+ && result.Kind != ResultKind.NewPB)
+ return;
+
+ var payload = new UploadPayload
+ {
+ SnapshotId = snapshot.SnapshotId,
+ Game = _gameTag,
+ SceneName = snapshot.Key.SceneName,
+ EntryFrom = snapshot.Key.EntryFromScene,
+ ExitTo = snapshot.Key.ExitToScene,
+ TotalTime = snapshot.TotalTime,
+ FrameCount = snapshot.Room.FrameCount,
+ CapturedAtUtcTicks = snapshot.CapturedAtUtcTicks,
+ ReplayData = snapshot.EncodedData,
+ ModVersion = _modVersion,
+ RetryCount = 0,
+ RetryAfterTicks = 0
+ };
+
+ _uploadWorker?.Enqueue(payload);
+ }
+
+ // ── Config fetch ───────────────────────────────────────────────────
+
+ private void FetchConfig()
+ {
+ try
+ {
+ var req = (HttpWebRequest)WebRequest.Create(
+ _apiBaseUrl + "/config");
+ req.Method = "GET";
+ req.Accept = "application/json";
+ req.Timeout = HttpTimeoutMs;
+ req.ReadWriteTimeout = HttpTimeoutMs;
+ req.Headers.Add("X-Device-Id", _deviceId);
+ req.Headers.Add("X-Mod-Version", _modVersion);
+
+ using (var resp = (HttpWebResponse)req.GetResponse())
+ using (var reader = new StreamReader(resp.GetResponseStream(),
+ Encoding.UTF8))
+ {
+ string json = reader.ReadToEnd();
+ var config = ApiJson.ParseConfigResponse(json);
+
+ PostToMain(() =>
+ {
+ _serverConfig = config;
+ _configFetched = true;
+ _maintenanceMode = config.Maintenance;
+
+ if (_maintenanceMode)
+ Log.LogInfo("[NetworkClient] Server in maintenance mode — " +
+ "uploads paused");
+
+ if (!string.IsNullOrEmpty(config.Announcement))
+ Log.LogInfo($"[NetworkClient] Server announcement: " +
+ config.Announcement);
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.LogInfo($"[NetworkClient] Config fetch failed (non-critical): " +
+ ex.Message);
+ // Config fetch failure is silent — everything still works.
+ // Defaults: no maintenance, no announcement.
+ }
+ }
+
+ // ── Internal handlers ──────────────────────────────────────────────
+
+ private void HandleUploadSuccess(UploadPayload payload, UploadResponse response)
+ {
+ // Already on main thread (dispatched by UploadWorker via PostToMain)
+ if (response.HasRank)
+ {
+ var rankInfo = new RankInfo(
+ payload.SceneName,
+ payload.EntryFrom,
+ payload.ExitTo,
+ payload.TotalTime,
+ response.Rank,
+ response.TotalRunners);
+ OnRankReceived?.Invoke(rankInfo);
+ }
+ }
+
+ private void HandleDisplayNameReceived(string name)
+ {
+ // Already on main thread
+ OnDisplayNameReceived?.Invoke(name);
+ }
+
+ // ── Thread-safe dispatch ───────────────────────────────────────────
+
+ private void PostToMain(Action action)
+ {
+ lock (_callbackLock)
+ {
+ _mainCallbacks.Enqueue(action);
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────
+
+ private static string TrimTrailingSlash(string url)
+ {
+ if (string.IsNullOrEmpty(url)) return url;
+ return url.EndsWith("/") ? url.Substring(0, url.Length - 1) : url;
+ }
+
+ // ── Public state queries (for UI) ──────────────────────────────────
+
+ public bool IsStarted => _started;
+ public bool IsMaintenanceMode => _maintenanceMode;
+ public bool HasServerConfig => _configFetched;
+ public string ServerAnnouncement =>
+ _serverConfig != null ? _serverConfig.Announcement : null;
+ }
+}
\ No newline at end of file
diff --git a/src/General/PBManager.cs b/src/General/PBManager.cs
index fa9b6e2..8f23c0d 100644
--- a/src/General/PBManager.cs
+++ b/src/General/PBManager.cs
@@ -67,6 +67,12 @@ public static void SetSelectionState(ReplaySelectionState? state)
return snapshot?.Room;
}
+ public static ReplaySnapshot? GetPBSnapshot(RoomKey key)
+ {
+ ReplaySnapshot snapshot;
+ return currentPbs.TryGetValue(key, out snapshot) ? snapshot : null;
+ }
+
public static IList GetHistory(RoomKey key)
{
if (!histories.TryGetValue(key, out var history))
diff --git a/src/General/RoomTimerHud.cs b/src/General/RoomTimerHud.cs
index 860c5ba..6c4f8e8 100644
--- a/src/General/RoomTimerHud.cs
+++ b/src/General/RoomTimerHud.cs
@@ -32,6 +32,7 @@ private enum HudState { Hidden, Ready, Running, Finished }
private Text? _deltaText;
private Text? _pbLabelText;
private Text? _pbTimeText;
+ private Text? _rankText;
private bool _setup = false;
@@ -118,6 +119,7 @@ public void Tick(bool shouldTick)
private void HandleRoomEnter(string sceneName, string entryFromScene)
{
+ if (_rankText != null) _rankText.gameObject.SetActive(false);
if (_state != HudState.Ready) return;
_entryPbTime = BestPBForEntry(sceneName, entryFromScene);
@@ -151,6 +153,8 @@ private void HandleRoomExit(string sceneName, string entryFromScene,
private void HandleDiscarded()
{
+ if (_rankText != null) _rankText.gameObject.SetActive(false);
+
if (GhostSettings.TimerHudEnabled)
{
_state = HudState.Ready;
@@ -292,7 +296,7 @@ private void BuildTimerWidget(Transform canvasRoot)
int pbTimeW = UIStyle.W(60);
int innerW = timerW + colGap + deltaW;
- int innerH = timerRowH + rowGap + pbRowH;
+ int innerH = timerRowH + rowGap + pbRowH + rowGap + pbRowH;
int mX = UIStyle.W(MARGIN_X);
int mY = UIStyle.H(MARGIN_Y);
@@ -336,6 +340,14 @@ private void BuildTimerWidget(Transform canvasRoot)
pbFontSz, UIStyle.Gold, TextAnchor.MiddleLeft,
x: pbTimeX, y: pbRowTop, w: pbTimeW, h: pbRowH);
_pbTimeText.alignByGeometry = false;
+
+ int rankRowTop = pbRowTop + pbRowH + rowGap;
+
+ _rankText = MakeLbl(_timerRootGO.transform, "",
+ pbFontSz, UIStyle.Accent, TextAnchor.MiddleLeft,
+ x: 0, y: rankRowTop, w: innerW, h: pbRowH);
+ _rankText.alignByGeometry = false;
+ _rankText.gameObject.SetActive(false);
}
private static Text MakeLbl(Transform parent, string text,
@@ -420,5 +432,21 @@ private static bool IsPaused()
}
catch { return false; }
}
+
+ ///
+ /// Shows the global rank below the PB row. Called from the main thread
+ /// when the upload response arrives with rank data.
+ ///
+ public void ShowRank(RankInfo rankInfo)
+ {
+ // Only show rank if we're in the Finished state and the rank
+ // matches the room we just finished
+ if (_state != HudState.Finished) return;
+ if (_rankText == null) return;
+
+ _rankText.text = $"#{rankInfo.Rank} / {rankInfo.TotalRunners}";
+ _rankText.color = UIStyle.Accent;
+ _rankText.gameObject.SetActive(true);
+ }
}
}
\ No newline at end of file
diff --git a/src/General/UI/ReplayUI.Actions.cs b/src/General/UI/ReplayUI.Actions.cs
index 17d440c..3d34fdf 100644
--- a/src/General/UI/ReplayUI.Actions.cs
+++ b/src/General/UI/ReplayUI.Actions.cs
@@ -1,35 +1,32 @@
using System.Linq;
using UnityEngine;
+using UnityEngine.UI;
namespace ReplayTimerMod
{
public partial class ReplayUI
{
- // ── Copy all (header) - clipboard ────────────────────────────────────
+ // -- Config tab: Copy all --
private void OnExportAllClicked()
{
var all = PBManager.AllPBs().Select(p => p.Value).ToList();
if (all.Count == 0)
{
- ShowExportFeedback("Nothing to copy", UIStyle.Subtext);
+ ShowButtonFeedback(exportAllCfgLbl, exportAllCfgBg, "Nothing to copy", UIStyle.Subtext);
return;
}
GUIUtility.systemCopyBuffer = ReplayShareEncoder.EncodeCollection(all);
- ShowExportFeedback($"{all.Count} copied", UIStyle.Accent);
+ ShowButtonFeedback(exportAllCfgLbl, exportAllCfgBg, all.Count + " copied", UIStyle.Accent);
Log.LogInfo($"[ReplayUI] Copied {all.Count} replays to clipboard");
}
- // ── Download all (header) - writes file to disk ───────────────────────
+ // -- Config tab: Export All (save to disk) --
private void OnDownloadAllClicked()
{
var all = PBManager.AllPBs().Select(p => p.Value).ToList();
- if (all.Count == 0)
- {
- ShowDownloadFeedback("Nothing to save", UIStyle.Subtext);
- return;
- }
+ if (all.Count == 0) return;
try
{
@@ -39,20 +36,15 @@ private void OnDownloadAllClicked()
"export");
System.IO.Directory.CreateDirectory(dir);
- string datePart = System.DateTime.Now.ToString("yyyy-MM-dd_HHmm");
- string countPart = $"{all.Count}";
- string fileName = $"Re_{datePart}_{countPart}.rtmc.txt";
-
+ string stamp = System.DateTime.Now.ToString("yyyy-MM-dd_HHmm");
+ string fileName = $"Re_{stamp}_{all.Count}.rtmc.txt";
string path = System.IO.Path.Combine(dir, fileName);
System.IO.File.WriteAllText(path, ReplayShareEncoder.EncodeCollection(all));
-
- ShowDownloadFeedback($"Saved {all.Count} to /export/", UIStyle.Accent);
Log.LogInfo($"[ReplayUI] Saved {all.Count} replays to {path}");
}
catch (System.Exception ex)
{
- ShowDownloadFeedback("Save failed", UIStyle.Red);
Log.LogError($"[ReplayUI] Download all failed: {ex.Message}");
}
}
@@ -65,38 +57,49 @@ private void OnOpenExportFolderClicked()
"export");
if (System.IO.Directory.Exists(dir))
- {
System.Diagnostics.Process.Start(dir);
- }
- else
+ }
+
+ // -- Config tab: Clear all (two-click confirm) --
+
+ private void OnClearAllClicked()
+ {
+ if (!clearAllPending)
{
- ShowDownloadFeedback("Nothing Exported", UIStyle.Red);
+ clearAllPending = true;
+ ShowButtonFeedback(clearAllCfgLbl, clearAllCfgBg, "Are you sure?", UIStyle.Red);
+ if (clearAllCfgBg != null) clearAllCfgBg.color = UIStyle.Red with { a = 0.55f };
+ return;
}
+
+ PBManager.DeleteAll();
+ selectedScene = null;
+ clearAllPending = false;
+ RefreshCurrentView();
+ Log.LogInfo("[ReplayUI] All replays cleared");
}
- private void ShowExportFeedback(string msg, Color color)
+ private void ResetClearAllConfirm()
{
- if (exportAllBtnLbl == null) return;
- exportAllBtnLbl.text = msg;
- exportAllBtnLbl.color = color;
- if (exportAllBtnImg != null) exportAllBtnImg.color = color with { a = 0.30f };
+ clearAllPending = false;
+ ShowButtonFeedback(clearAllCfgLbl, clearAllCfgBg, "Clear all data", UIStyle.Red);
+ if (clearAllCfgBg != null) clearAllCfgBg.color = UIStyle.Red with { a = 0.15f };
+ ShowButtonFeedback(exportAllCfgLbl, exportAllCfgBg, "Copy all", UIStyle.Accent);
+ if (exportAllCfgBg != null) exportAllCfgBg.color = UIStyle.Accent with { a = 0.18f };
}
- private void ShowDownloadFeedback(string msg, Color color)
+ private static void ShowButtonFeedback(Text? label, Image? bg, string msg, Color color)
{
- if (downloadAllBtnLbl == null) return;
- downloadAllBtnLbl.text = msg;
- downloadAllBtnLbl.color = color;
- if (downloadAllBtnImg != null) downloadAllBtnImg.color = color with { a = 0.30f };
+ if (label != null) { label.text = msg; label.color = color; }
}
- // ── Export scene (sub-header) ─────────────────────────────────────────
+ // -- Scene-level actions --
private void OnExportSceneClicked()
{
if (selectedScene == null)
{
- ShowPasteStatus("Select a scene first", UIStyle.Subtext);
+ ShowPasteStatus("Select a room first", UIStyle.Subtext);
return;
}
var entries = PBManager.AllPBs()
@@ -109,107 +112,38 @@ private void OnExportSceneClicked()
return;
}
GUIUtility.systemCopyBuffer = ReplayShareEncoder.EncodeCollection(entries);
- ShowPasteStatus($"{entries.Count} routes copied", UIStyle.Accent);
+ ShowPasteStatus(entries.Count + " routes copied", UIStyle.Accent);
Log.LogInfo($"[ReplayUI] Exported {entries.Count} routes for {selectedScene}");
}
- // ── Per-snapshot ──────────────────────────────────────────────────────
-
- private void CopyReplay(RoomKey key, string snapshotId)
- {
- var snapshot = PBManager.GetHistory(key)
- .FirstOrDefault(s => s.SnapshotId == snapshotId);
- if (snapshot == null)
- {
- Log.LogWarning($"[ReplayUI] No snapshot for {key}#{snapshotId}");
- return;
- }
-
- GUIUtility.systemCopyBuffer = snapshot.EncodedData;
- Log.LogInfo($"[ReplayUI] Copied {key}#{snapshotId}");
- }
-
- private void DeleteSnapshot(RoomKey key, string snapshotId)
- {
- PBManager.DeleteSnapshot(key, snapshotId);
- if (selectedScene != null) RebuildRight(selectedScene);
- else RefreshSettingsBar();
- RebuildLeft();
- }
-
- private void DeleteRoute(RoomKey key)
- {
- PBManager.DeletePB(key);
- if (selectedScene != null) RebuildRight(selectedScene);
- else RefreshSettingsBar();
- RebuildLeft();
- }
-
- // ── Clear scene (sub-header) ──────────────────────────────────────────
-
private void OnClearSceneClicked()
{
if (selectedScene == null) return;
PBManager.DeleteScene(selectedScene);
selectedScene = null;
- ClearRight();
- RebuildLeft();
- RefreshSettingsBar();
- }
-
- // ── Global clear-all (header) - two-click confirm ─────────────────────
-
- private void OnClearAllClicked()
- {
- if (!clearAllPending)
- {
- clearAllPending = true;
- if (clearAllBtnLbl != null) clearAllBtnLbl.text = "Are you sure?";
- if (clearAllBtnImg != null) clearAllBtnImg.color = UIStyle.Red with { a = 0.55f };
- }
- else
- {
- PBManager.DeleteAll();
- selectedScene = null;
- ClearRight();
- RebuildLeft();
- RefreshSettingsBar();
- ResetClearAllConfirm();
- Log.LogInfo("[ReplayUI] All replays cleared");
- }
- }
-
- private void ResetClearAllConfirm()
- {
- clearAllPending = false;
- if (clearAllBtnLbl != null) clearAllBtnLbl.text = "Clear all";
- if (clearAllBtnImg != null) clearAllBtnImg.color = UIStyle.Red with { a = 0.22f };
- if (exportAllBtnLbl != null) exportAllBtnLbl.text = "Copy all";
- if (exportAllBtnImg != null) exportAllBtnImg.color = UIStyle.Accent with { a = 0.22f };
- if (downloadAllBtnLbl != null) downloadAllBtnLbl.text = "Download all";
- if (downloadAllBtnImg != null) downloadAllBtnImg.color = UIStyle.Accent with { a = 0.15f };
+ ClearSelectedScene();
+ RebuildSceneList();
}
- // ── Paste ─────────────────────────────────────────────────────────────
+ // -- Paste --
private void OnPasteClicked()
{
string clip = GUIUtility.systemCopyBuffer ?? "";
if (string.IsNullOrEmpty(clip))
{
- ShowPasteStatus("✕ Clipboard empty", UIStyle.Red);
+ ShowPasteStatus("Clipboard empty", UIStyle.Red);
return;
}
var rooms = ReplayShareEncoder.DecodeShareString(clip);
if (rooms.Count == 0)
{
- ShowPasteStatus("✕ Invalid data", UIStyle.Red);
+ ShowPasteStatus("Invalid data", UIStyle.Red);
return;
}
- int imported = 0;
- int duplicates = 0;
+ int imported = 0, duplicates = 0;
foreach (var room in rooms)
{
if (PBManager.ImportPB(room)) imported++;
@@ -217,19 +151,17 @@ private void OnPasteClicked()
}
selectedScene = rooms[0].Key.SceneName;
- RebuildLeft();
- RebuildRight(selectedScene);
- RefreshSettingsBar();
+ RebuildSceneList();
+ UpdateRightSubHeader();
+ RebuildRightContent();
string status;
if (rooms.Count == 1)
- {
status = imported > 0 ? rooms[0].Key.SceneName : "Duplicate replay";
- }
else
{
- status = imported > 0 ? $"{imported} imported" : "No new replays";
- if (duplicates > 0) status += $" ({duplicates} duplicate)";
+ status = imported > 0 ? imported + " imported" : "No new replays";
+ if (duplicates > 0) status += " (" + duplicates + " dup)";
}
ShowPasteStatus(status, imported > 0 ? UIStyle.Gold : UIStyle.Subtext);
@@ -238,218 +170,224 @@ private void OnPasteClicked()
private void ShowPasteStatus(string msg, Color color)
{
- if (pasteStatus == null) return;
- pasteStatus.text = msg;
- pasteStatus.color = color;
+ if (pasteStatusLbl != null)
+ {
+ pasteStatusLbl.text = msg;
+ pasteStatusLbl.color = color;
+ }
}
- // ── Jump to current room (left sub-header) ────────────────────────────
+ // -- Snapshot actions --
- private void OnJumpToCurrentClicked()
+ private void CopyReplay(RoomKey key, string snapshotId)
{
- string scene = RoomTracker.CurrentScene;
-
- if (string.IsNullOrEmpty(scene))
+ var snapshot = PBManager.GetHistory(key)
+ .FirstOrDefault(s => s.SnapshotId == snapshotId);
+ if (snapshot == null)
{
- ShowJumpFeedback("Not in a room", UIStyle.Subtext);
+ Log.LogWarning($"[ReplayUI] No snapshot for {key}#{snapshotId}");
return;
}
+ GUIUtility.systemCopyBuffer = snapshot.EncodedData;
+ Log.LogInfo($"[ReplayUI] Copied {key}#{snapshotId}");
+ }
+
+ private void DeleteSnapshot(RoomKey key, string snapshotId)
+ {
+ PBManager.DeleteSnapshot(key, snapshotId);
+ RebuildSceneList();
+ if (selectedScene != null && activeTab == TabKind.Runs)
+ RebuildRightContent();
+ }
+
+ private void DeleteRoute(RoomKey key)
+ {
+ PBManager.DeletePB(key);
+ RebuildSceneList();
+ if (selectedScene != null && activeTab == TabKind.Runs)
+ RebuildRightContent();
+ }
+
+ private void SelectSnapshotForEditing(RoomKey key, string snapshotId)
+ {
+ var snapshot = PBManager.GetSnapshot(key, snapshotId);
+ if (snapshot == null) return;
- bool hasPB = PBManager.AllPBs().Any(p => p.Key.SceneName == scene);
- if (!hasPB)
+ if (!snapshot.HasVisualOverride)
{
- ShowJumpFeedback($"No PB", UIStyle.Subtext);
- return;
+ Color color = snapshot.ResolveGhostColor(CurrentGlobalGhostColor);
+ if (!PBManager.UpdateSnapshotVisuals(key, snapshotId, true, color))
+ return;
}
- ResetJumpFeedback();
- SelectScene(scene);
- ScrollToScene(scene);
-
- Log.LogInfo($"[ReplayUI] Jumped to current room: {scene}");
+ SelectionState?.SelectSnapshot(snapshotId);
+ if (activeTab == TabKind.Runs && selectedScene == key.SceneName)
+ RebuildRightContent();
+ if (activeTab == TabKind.Config)
+ RefreshConfigValues();
}
- private void ShowJumpFeedback(string msg, Color color)
+ private void ToggleSnapshotPlayback(RoomKey key, string snapshotId)
{
- if (jumpToCurrentBtnLbl == null) return;
- jumpToCurrentBtnLbl.text = msg;
- jumpToCurrentBtnLbl.color = color;
- if (jumpToCurrentBtnImg != null)
- jumpToCurrentBtnImg.color = color with { a = 0.18f };
+ SelectionState?.TogglePlayback(snapshotId);
+ if (activeTab == TabKind.Runs && selectedScene == key.SceneName)
+ RebuildRightContent();
}
- private void ResetJumpFeedback()
+ // -- Jump navigation --
+
+ private void OnJumpToCurrentClicked()
{
- if (jumpToCurrentBtnLbl == null) return;
- jumpToCurrentBtnLbl.text = "Current";
- jumpToCurrentBtnLbl.color = UIStyle.Gold;
- if (jumpToCurrentBtnImg != null)
- jumpToCurrentBtnImg.color = UIStyle.Gold with { a = 0.18f };
+ string scene = RoomTracker.CurrentScene;
+ if (string.IsNullOrEmpty(scene))
+ {
+ ShowButtonFeedback(jumpCurrentLbl, jumpCurrentBg, "Not in a room", UIStyle.Subtext);
+ return;
+ }
+ if (!PBManager.AllPBs().Any(p => p.Key.SceneName == scene))
+ {
+ ShowButtonFeedback(jumpCurrentLbl, jumpCurrentBg, "No PB", UIStyle.Subtext);
+ return;
+ }
+
+ ResetJumpFeedback();
+ if (activeTab != TabKind.Runs)
+ SwitchTab(TabKind.Runs);
+ SelectScene(scene);
+ ScrollToScene(scene);
}
- // ── Jump to previous room (left sub-header) ───────────────────────────
-
private void OnJumpToLastClicked()
{
string scene = RoomTracker.PreviousScene;
-
if (string.IsNullOrEmpty(scene))
{
- ShowJumpLastFeedback("No previous", UIStyle.Subtext);
+ ShowButtonFeedback(jumpPreviousLbl, jumpPreviousBg, "No previous", UIStyle.Subtext);
return;
}
-
- bool hasPB = PBManager.AllPBs().Any(p => p.Key.SceneName == scene);
- if (!hasPB)
+ if (!PBManager.AllPBs().Any(p => p.Key.SceneName == scene))
{
- ShowJumpLastFeedback($"No PB", UIStyle.Subtext);
+ ShowButtonFeedback(jumpPreviousLbl, jumpPreviousBg, "No PB", UIStyle.Subtext);
return;
}
ResetJumpLastFeedback();
+ if (activeTab != TabKind.Runs)
+ SwitchTab(TabKind.Runs);
SelectScene(scene);
ScrollToScene(scene);
-
- Log.LogInfo($"[ReplayUI] Jumped to previous room: {scene}");
}
- private void ShowJumpLastFeedback(string msg, Color color)
+ private void ResetJumpFeedback()
{
- if (jumpToLastBtnLbl == null) return;
- jumpToLastBtnLbl.text = msg;
- jumpToLastBtnLbl.color = color;
- if (jumpToLastBtnImg != null)
- jumpToLastBtnImg.color = color with { a = 0.18f };
+ if (jumpCurrentLbl != null) { jumpCurrentLbl.text = "Current"; jumpCurrentLbl.color = UIStyle.Gold; }
+ if (jumpCurrentBg != null) jumpCurrentBg.color = UIStyle.Gold with { a = 0.18f };
}
private void ResetJumpLastFeedback()
{
- if (jumpToLastBtnLbl == null) return;
- jumpToLastBtnLbl.text = "Previous";
- jumpToLastBtnLbl.color = UIStyle.Accent;
- if (jumpToLastBtnImg != null)
- jumpToLastBtnImg.color = UIStyle.Accent with { a = 0.18f };
+ if (jumpPreviousLbl != null) { jumpPreviousLbl.text = "Previous"; jumpPreviousLbl.color = UIStyle.Accent; }
+ if (jumpPreviousBg != null) jumpPreviousBg.color = UIStyle.Accent with { a = 0.18f };
}
- // ── Ghost settings ────────────────────────────────────────────────────
+ // -- Settings toggles --
private void OnTrackingToggle()
{
GhostSettings.TrackingEnabled = !GhostSettings.TrackingEnabled;
- RefreshSettingsBar();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
}
private void OnGhostToggle()
{
GhostSettings.GhostEnabled = !GhostSettings.GhostEnabled;
- RefreshSettingsBar();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
}
private void OnSavePolicyToggle()
{
GhostSettings.SaveAllRunsEnabled = !GhostSettings.SaveAllRunsEnabled;
- RefreshSettingsBar();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
}
- private void OnEditGlobalContext()
+ private void OnOnlineToggle()
{
- SelectionState?.SelectSnapshot(null);
- RefreshSettingsBar();
- if (selectedScene != null) RebuildRight(selectedScene);
+ bool enabling = !GhostSettings.OnlineEnabled;
+ GhostSettings.OnlineEnabled = enabling;
+ _onOnlineToggle?.Invoke(enabling);
+ if (activeTab == TabKind.Config) RefreshConfigValues();
}
- private void SelectSnapshotForEditing(RoomKey key, string snapshotId)
- {
- var snapshot = PBManager.GetSnapshot(key, snapshotId);
- if (snapshot == null)
- return;
-
- if (!snapshot.HasVisualOverride)
- {
- Color color = snapshot.ResolveGhostColor(CurrentGlobalGhostColor);
- if (!PBManager.UpdateSnapshotVisuals(key, snapshotId, true, color))
- return;
- }
+ private void OnMaxSavedReplaysMinus() => AdjustMaxSaved(-1);
+ private void OnMaxSavedReplaysPlus() => AdjustMaxSaved(1);
- SelectionState?.SelectSnapshot(snapshotId);
- RefreshSettingsBar();
- if (selectedScene == key.SceneName)
- RebuildRight(key.SceneName);
+ private void AdjustMaxSaved(int delta)
+ {
+ GhostSettings.MaxSavedReplaysPerRoute += delta;
+ PBManager.PruneAllHistories(GhostSettings.MaxSavedReplaysPerRoute, persist: true);
+ if (activeTab == TabKind.Config) RefreshConfigValues();
+ if (activeTab == TabKind.Runs && selectedScene != null)
+ RebuildRightContent();
}
- private void ToggleSnapshotPlayback(RoomKey key, string snapshotId)
+ private void OnEditGlobalContext()
{
- SelectionState?.TogglePlayback(snapshotId);
- if (selectedScene == key.SceneName)
- RebuildRight(key.SceneName);
- else
- RefreshSettingsBar();
+ SelectionState?.SelectSnapshot(null);
+ if (activeTab == TabKind.Config) RefreshConfigValues();
+ if (activeTab == TabKind.Runs && selectedScene != null)
+ RebuildRightContent();
}
private void OnAlphaMinus() => AdjustAlpha(-0.05f);
-
private void OnAlphaPlus() => AdjustAlpha(0.05f);
private void AdjustAlpha(float delta)
{
if (TryGetSelectedSnapshot(out var key, out var snapshot) && snapshot != null)
{
- if (!snapshot.HasVisualOverride)
- return;
+ if (!snapshot.HasVisualOverride) return;
Color color = snapshot.ResolveGhostColor(CurrentGlobalGhostColor);
color.a = Mathf.Clamp01(Mathf.Round((color.a + delta) * 20f) / 20f);
- if (PBManager.UpdateSnapshotVisuals(key, snapshot.SnapshotId, true, color)
- && selectedScene == key.SceneName)
- RebuildRight(key.SceneName);
+ PBManager.UpdateSnapshotVisuals(key, snapshot.SnapshotId, true, color);
}
else
{
GhostSettings.GhostAlpha = Mathf.Round((GhostSettings.GhostAlpha + delta) * 20f) / 20f;
- if (selectedScene != null)
- RebuildRight(selectedScene);
}
- RefreshSettingsBar();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
+ if (activeTab == TabKind.Runs && selectedScene != null)
+ RebuildRightContent();
}
private void OnColorSwatch(Color rgb)
{
if (TryGetSelectedSnapshot(out var key, out var snapshot) && snapshot != null)
{
- if (!snapshot.HasVisualOverride)
- return;
+ if (!snapshot.HasVisualOverride) return;
Color color = snapshot.ResolveGhostColor(CurrentGlobalGhostColor);
color.r = rgb.r;
color.g = rgb.g;
color.b = rgb.b;
- if (PBManager.UpdateSnapshotVisuals(key, snapshot.SnapshotId, true, color)
- && selectedScene == key.SceneName)
- RebuildRight(key.SceneName);
+ PBManager.UpdateSnapshotVisuals(key, snapshot.SnapshotId, true, color);
}
else
{
GhostSettings.GhostColor = new Color(rgb.r, rgb.g, rgb.b, GhostSettings.GhostAlpha);
- if (selectedScene != null)
- RebuildRight(selectedScene);
}
- RefreshSettingsBar();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
+ if (activeTab == TabKind.Runs && selectedScene != null)
+ RebuildRightContent();
}
- private static string AlphaString() =>
- GhostSettings.GhostAlpha.ToString("0.00");
-
- // ── Timer HUD settings ────────────────────────────────────────────────
private void OnTimerToggleClicked()
{
GhostSettings.TimerHudEnabled = !GhostSettings.TimerHudEnabled;
- if (!GhostSettings.TimerHudEnabled && timerHud != null)
- {
- timerHud.Disarm();
- }
- RefreshSettingsBar();
+ if (!GhostSettings.TimerHudEnabled) timerHud?.Disarm();
+ if (activeTab == TabKind.Config) RefreshConfigValues();
}
}
}
\ No newline at end of file
diff --git a/src/General/UI/ReplayUI.Build.cs b/src/General/UI/ReplayUI.Build.cs
index 4887a75..46eefcf 100644
--- a/src/General/UI/ReplayUI.Build.cs
+++ b/src/General/UI/ReplayUI.Build.cs
@@ -5,7 +5,6 @@ namespace ReplayTimerMod
{
public partial class ReplayUI
{
- // ── Tab button (bottom-left ≡) ────────────────────────────────────────
private void BuildTab()
{
tabGO = MakeGO("ReplayTab", canvasGO!.transform);
@@ -16,13 +15,12 @@ private void BuildTab()
rt.anchorMin = rt.anchorMax = Vector2.zero;
rt.pivot = Vector2.zero;
rt.anchoredPosition = new Vector2(M, M);
- rt.sizeDelta = new Vector2(TW, TH);
+ rt.sizeDelta = new Vector2(UIStyle.TabBtnWidth, UIStyle.TabBtnHeight);
- MakeLbl(tabGO.transform, "≡", UIStyle.FontSizeLg,
+ MakeLbl(tabGO.transform, "\u2261", UIStyle.FontSizeLg,
UIStyle.Text, TextAnchor.MiddleCenter, fill: true);
}
- // ── Panel ─────────────────────────────────────────────────────────────
private void BuildPanel()
{
panelGO = MakeGO("ReplayPanel", canvasGO!.transform);
@@ -31,378 +29,194 @@ private void BuildPanel()
var rt = panelGO.GetComponent();
rt.anchorMin = rt.anchorMax = Vector2.zero;
rt.pivot = Vector2.zero;
- rt.anchoredPosition = new Vector2(M, M + TH + M);
+ rt.anchoredPosition = new Vector2(M, M + UIStyle.TabBtnHeight + M);
rt.sizeDelta = new Vector2(PW, PH);
- BuildPanelHeader();
+ int HDR = UIStyle.HeaderHeight;
+ int SRCH = UIStyle.SearchBarHeight;
+ int TABH = UIStyle.TabBarHeight;
+ int SUBH = UIStyle.SubHeaderHeight;
+ int FOOT = UIStyle.FooterHeight;
+
+ BuildPanelHeader(HDR);
+ HLine(panelGO.transform, 0, HDR, PW);
int bodyY = HDR + 1;
- int bodyH = PH - bodyY - 1 - STGSH;
- int scrollBodyH = bodyH - SUBHDR - 1;
- BuildLeftSubHeader(bodyY);
- BuildRightSubHeader(bodyY);
- HLine(panelGO.transform, 0, bodyY + SUBHDR, PW);
+ // Left panel
+ int searchY = bodyY;
+ int footerY = PH - FOOT;
+ int sceneListY = searchY + SRCH + 1;
+ int sceneListH = footerY - 1 - sceneListY;
+
+ BuildSearchBar(searchY, SRCH);
+ HLine(panelGO.transform, 0, sceneListY - 1, LW);
+
+ sceneListContent = BuildScrollArea(panelGO.transform, "SceneListScroll",
+ 0, sceneListY, LW, sceneListH);
+ sceneListScroll = sceneListContent.parent.parent.GetComponent();
+
+ HLine(panelGO.transform, 0, footerY - 1, LW);
+ BuildLeftFooter(footerY, FOOT);
- VLine(panelGO.transform, LW, bodyY, bodyH);
+ // Vertical divider
+ VLine(panelGO.transform, LW, bodyY, PH - bodyY);
- leftContent = BuildScrollArea(panelGO.transform, "LeftScroll",
- 0, bodyY + SUBHDR + 1, LW, scrollBodyH);
- leftScrollRect = leftContent.parent.parent.GetComponent();
+ // Right panel
+ int tabBarY = bodyY;
+ int rightSubY = tabBarY + TABH + 1;
+ int rightContentY = rightSubY + SUBH + 1;
+ int rightContentH = PH - rightContentY;
- rightContent = BuildScrollArea(panelGO.transform, "RightScroll",
- LW + 1, bodyY + SUBHDR + 1, RW, scrollBodyH);
+ BuildTabBar(tabBarY, TABH);
+ HLine(panelGO.transform, LW + 1, tabBarY + TABH, RW);
+ BuildRightSubHeader(rightSubY, SUBH);
+ HLine(panelGO.transform, LW + 1, rightContentY - 1, RW);
- HLine(panelGO.transform, 0, PH - STGSH - 1, PW);
- BuildSettingsBar();
+ rightContent = BuildScrollArea(panelGO.transform, "RightContentScroll",
+ LW + 1, rightContentY, RW, rightContentH);
}
- // ── Panel header: "Replay Times" [Export all] [Clear all] [-] ──────
- private void BuildPanelHeader()
+ private void BuildPanelHeader(int height)
{
var hdr = MakeGO("Header", panelGO!.transform);
Img(hdr, UIStyle.Surface);
- Rect(hdr, 0, 0, PW, HDR);
- HLine(panelGO.transform, 0, HDR, PW);
-
- var collBtn = MakeGO("Collapse", hdr.transform);
- Img(collBtn, UIStyle.Overlay);
- Btn(collBtn, TogglePanel);
- Rect(collBtn, PW - HDR, 0, HDR, HDR);
- MakeLbl(collBtn.transform, "-", UIStyle.FontSizeLg,
- UIStyle.Text, TextAnchor.MiddleCenter, fill: true);
+ Rect(hdr, 0, 0, PW, height);
- int btnH = UIStyle.H(22);
- int btnY = (HDR - btnH) / 2;
-
- int clearW = UIStyle.W(76);
- int clearX = PW - HDR - M - clearW;
- var clearGO = MakeGO("ClearAll", hdr.transform);
- Rect(clearGO, clearX, btnY, clearW, btnH);
- clearAllBtnImg = clearGO.AddComponent();
- clearAllBtnImg.color = UIStyle.Red with { a = 0.22f };
- clearGO.AddComponent