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