using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; public class LocalPlayerData { /// /// maximum number of the best online scores to keep track of /// public const int MaxBestOnlineScores = 10; /// /// maximum number of recent local scores to keep track of /// public const int MaxRecentLocalScores = 10; /// /// queue of the best online scores, /// used in user rating calculation and accuracy display stats /// public Queue BestOnlineScores = new(20); /// /// last known email used /// public string LastKnownEmail = ""; /// /// last known username used /// public string LastKnownUsername = "Guest"; /// /// queue of the 10 most recent local scores /// public Queue RecentLocalScores = new(10); /// /// queue of the 10 most recent online scores, /// used in user rating calculation and accuracy display stats /// public Queue RecentOnlineScores = new(10); /// /// loads player data from player prefs and database /// public void LoadFromTheWorld(Action callback) { // load user data, possibly from the backend var possibleUser = GameManager.Instance.Backend.GetUser(); var currentKnownEmail = string.Empty; var currentKnownUsername = string.Empty; if (possibleUser != null) { currentKnownEmail = possibleUser.Email; currentKnownUsername = GameManager.Instance.Backend.GetUsername(); } var lastStoredEmail = PlayerPrefs.GetString("LastKnownEmail", ""); var lastStoredUsername = PlayerPrefs.GetString("LastKnownUsername", "Guest"); LastKnownEmail = string.IsNullOrEmpty(currentKnownEmail) ? lastStoredEmail : currentKnownEmail; LastKnownUsername = string.IsNullOrEmpty(currentKnownUsername) ? lastStoredUsername : currentKnownUsername; // load local scores RecentLocalScores.Clear(); for (var idx = 0; idx < 10; idx++) { var timestampRaw = PlayerPrefs.GetString($"RecentLocalScores_{idx}_Timestamp", ""); if (timestampRaw == "") continue; var timestamp = DateTime.TryParseExact(timestampRaw, "s", CultureInfo.InvariantCulture, DateTimeStyles.None, out var t) ? t : DateTime.MinValue; if (timestamp == DateTime.MinValue) continue; var noOfRounds = PlayerPrefs.GetInt($"RecentLocalScores_{idx}_NoOfRounds", -1); var l = PlayerPrefs.GetFloat($"RecentLocalScores_{idx}_AvgLightnessAccuracy", -1f); var c = PlayerPrefs.GetFloat($"RecentLocalScores_{idx}_AvgChromaAccuracy", -1f); var h = PlayerPrefs.GetFloat($"RecentLocalScores_{idx}_AvgHueAccuracy", -1f); var e = PlayerPrefs.GetFloat($"RecentLocalScores_{idx}_AvgPerceivedAccuracy", -1f); // if any of the values are invalid, don't add the score if (noOfRounds < 0 || l < 0 || c < 0 || h < 0 || e < 0) continue; RegisterLocalScore(new Score(timestamp, Math.Max(1, noOfRounds), Math.Clamp(l, 0f, 100f), Math.Clamp(c, 0f, 100f), Math.Clamp(h, 0f, 100f))); } // load online scores RecentOnlineScores.Clear(); GameManager.Instance.Backend.GetRecentScores((_, recentOnlineScores) => { foreach (var onlineScore in recentOnlineScores) { if (RecentOnlineScores.Count > 10) RecentOnlineScores.Dequeue(); RecentOnlineScores.Enqueue(onlineScore); } Debug.Log( $"loaded lpdata from the world ({LastKnownUsername} <{LastKnownEmail}> with RLS.Count={RecentLocalScores.Count}, ROS.Count={RecentOnlineScores.Count}"); callback(this); }); } /// /// saves player data to player prefs /// public void SaveToTheWorld() { PlayerPrefs.SetString("LastKnownEmail", LastKnownEmail); PlayerPrefs.SetString("LastKnownUsername", LastKnownUsername); var idx = 0; foreach (var score in RecentLocalScores) { PlayerPrefs.SetString($"RecentLocalScores_{idx}_Timestamp", score.Timestamp.ToString("s", CultureInfo.InvariantCulture)); PlayerPrefs.SetInt($"RecentLocalScores_{idx}_NoOfRounds", score.NoOfRounds); PlayerPrefs.SetFloat($"RecentLocalScores_{idx}_AvgLightnessAccuracy", score.AvgLightnessAccuracy); PlayerPrefs.SetFloat($"RecentLocalScores_{idx}_AvgChromaAccuracy", score.AvgChromaAccuracy); PlayerPrefs.SetFloat($"RecentLocalScores_{idx}_AvgHueAccuracy", score.AvgHueAccuracy); PlayerPrefs.SetFloat($"RecentLocalScores_{idx}_AvgPerceivedAccuracy", score.AvgPerceivedAccuracy); idx++; } Debug.Log("saved lpdata to playerprefs"); // online scores are already saved in the backend } /// /// registers a score to the player's local data /// /// the score to register public void RegisterLocalScore(Score score) { while (RecentLocalScores.Count >= MaxRecentLocalScores) RecentLocalScores.Dequeue(); RecentLocalScores.Enqueue(score); } /// /// calculates the user rating based on whatever local data is available /// /// the user rating (0-100f) public float CalculateUserRating() { // user rating is like CHUNITHM's rating system // where best + recent scores are averaged out // in this case 20 best scores, and 10 recent scores are used // ensure the scores don't exceed their arbitrary limits while (RecentOnlineScores.Count > MaxRecentLocalScores) RecentOnlineScores.Dequeue(); while (RecentLocalScores.Count > MaxRecentLocalScores) RecentLocalScores.Dequeue(); while (BestOnlineScores.Count > MaxBestOnlineScores) BestOnlineScores.Dequeue(); // if online scores are available, use them var recentScores = RecentOnlineScores.Count > 0 ? RecentOnlineScores : RecentLocalScores; var bestScores = BestOnlineScores; var scores = 0; var totalRating = 0d; foreach (var score in recentScores) { scores++; var dL = score.AvgLightnessAccuracy; var dC = score.AvgChromaAccuracy; var dH = score.AvgHueAccuracy; var dE = Math.Sqrt(score.AvgLightnessAccuracy * score.AvgLightnessAccuracy + score.AvgChromaAccuracy * score.AvgChromaAccuracy + score.AvgHueAccuracy * score.AvgHueAccuracy); totalRating = (dL + dC + dH + dE) / 4d; } foreach (var score in bestScores) { scores++; var dL = score.AvgLightnessAccuracy; var dC = score.AvgChromaAccuracy; var dH = score.AvgHueAccuracy; var dE = Math.Sqrt(score.AvgLightnessAccuracy * score.AvgLightnessAccuracy + score.AvgChromaAccuracy * score.AvgChromaAccuracy + score.AvgHueAccuracy * score.AvgHueAccuracy); totalRating = (dL + dC + dH + dE) / 4d; } return Math.Clamp((float)(totalRating / scores), 0f, 100f); } public struct Score { /// /// timestamp of the score /// public DateTime Timestamp; /// /// number of rounds played (0-100) /// public readonly int NoOfRounds; /// /// average lightness accuracy across all rounds (0-100) /// public readonly float AvgLightnessAccuracy; /// /// average chroma accuracy across all rounds (0-100) /// public readonly float AvgChromaAccuracy; /// /// average hue accuracy across all rounds (0-100) /// public readonly float AvgHueAccuracy; /// /// average perceived accuracy across all rounds (0-100) /// public readonly float AvgPerceivedAccuracy; /// /// constructor for the score struct /// /// timestamp of the score /// number of rounds played (0-100) /// average lightness accuracy across all rounds (0-100) /// average chroma accuracy across all rounds (0-100) /// average hue accuracy across all rounds (0-100) /// /// average perceived accuracy across all rounds (0-100) public Score(DateTime timestamp = new(), int noOfRounds = 1, float l = 100.0f, float c = 100.0f, float h = 100.0f, float e = 100.0f) { Timestamp = timestamp; NoOfRounds = noOfRounds; AvgLightnessAccuracy = l; AvgChromaAccuracy = c; AvgHueAccuracy = h; AvgPerceivedAccuracy = e; } /// /// dict-based constructor for the score struct /// /// dictionary of the score data /// thrown if the dictionary is malformed or missing data public Score(Dictionary data) { // try to safely construct the score from a backend-provided dictionary // for each value, if it's not found, or not a valid value, throw an exception if (!data.ContainsKey("timestamp") || data["timestamp"] is not long timestamp) throw new ArgumentException("timestamp not found or invalid"); if (!data.ContainsKey("noOfRounds") || data["noOfRounds"] is not int noOfRounds) throw new ArgumentException("noOfRounds not found or invalid"); if (!data.ContainsKey("avgLightnessAccuracy") || data["avgLightnessAccuracy"] is not float avgLightnessAccuracy) throw new ArgumentException("avgLightnessAccuracy not found or invalid"); if (!data.ContainsKey("avgChromaAccuracy") || data["avgChromaAccuracy"] is not float avgChromaAccuracy) throw new ArgumentException("avgChromaAccuracy not found or invalid"); if (!data.ContainsKey("avgHueAccuracy") || data["avgHueAccuracy"] is not float avgHueAccuracy) throw new ArgumentException("avgHueAccuracy not found or invalid"); if (!data.ContainsKey("avgPerceivedAccuracy") || data["avgPerceivedAccuracy"] is not float avgPerceivedAccuracy) throw new ArgumentException("avgPerceivedAccuracy not found or invalid"); Timestamp = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; NoOfRounds = noOfRounds; AvgLightnessAccuracy = avgLightnessAccuracy; AvgChromaAccuracy = avgChromaAccuracy; AvgHueAccuracy = avgHueAccuracy; AvgPerceivedAccuracy = avgPerceivedAccuracy; } /// /// converts the score struct to a dictionary safe for the backend /// /// public Dictionary ToDictionary() { return new Dictionary { { "timestamp", new DateTimeOffset(Timestamp).ToUnixTimeSeconds() }, { "noOfRounds", NoOfRounds }, { "avgLightnessAccuracy", AvgLightnessAccuracy }, { "avgChromaAccuracy", AvgChromaAccuracy }, { "avgHueAccuracy", AvgHueAccuracy }, { "avgPerceivedAccuracy", AvgPerceivedAccuracy } }; } } }