using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.UIElements; /// <summary> /// singleton for a single source of truth game state and flow management /// </summary> public class GameManager : MonoBehaviour { /// <summary> /// singleton pattern: define instance field for accessing the singleton elsewhere /// </summary> public static GameManager Instance; /// <summary> /// ui manager object for handling ui state and flow /// </summary> public UIManager ui; /// <summary> /// list of callbacks to call when the local player data changes /// </summary> private readonly List<Action<LocalPlayerData>> _onLocalPlayerDataChangeCallbacks = new(); /// <summary> /// the local player data object for storing player data /// </summary> private LocalPlayerData _data; /// <summary> /// backend object for handling communication with the firebase backend /// </summary> public Backend Backend; /// <summary> /// gameplay object for handling game loop /// </summary> public Gameplay Gameplay; /// <summary> /// read-only property for accessing the local player data outside this class /// </summary> public LocalPlayerData Data => _data; /// <summary> /// enforces singleton behaviour; sets doesn't destroy on load and checks for multiple instances /// </summary> private void Awake() { // check if instance hasn't been set yet if (Instance == null) { Debug.Log("awake as singleton instance, setting self as the forever-alive instance"); Instance = this; DontDestroyOnLoad(gameObject); } // check if instance is already set and it's not this instance else if (Instance != null && Instance != this) { Debug.Log("awake as non-singleton instance, destroying self"); Destroy(gameObject); } } /// <summary> /// start modifying state /// </summary> private void Start() { Debug.Log("GameManager starts here"); _data.LoadFromTheWorld(FireLocalPlayerDataChangeCallbacks); } /// <summary> /// initialise variables and ui elements /// </summary> private void OnEnable() { ui = UIManager.Instance; // load the local player data and refresh the ui _data = new LocalPlayerData(); Backend = new Backend(); Backend.Initialise(status => { Debug.Log("initialised backend, setting connection status text"); ui.UI.Q<Label>("ConnectionStatusText").text = status switch { Backend.FirebaseConnectionStatus.Connected => "Status: Connected", Backend.FirebaseConnectionStatus.Updating => "Status: Updating... (Retrying in a bit!)", Backend.FirebaseConnectionStatus.NotConnected => "Status: Disconnected", Backend.FirebaseConnectionStatus.UpdateRequired => "Status: Disconnected (Device Component Update Required)", Backend.FirebaseConnectionStatus.ExternalError => "Status: Disconnected (External/Device Error)", Backend.FirebaseConnectionStatus.InternalError => "Status: Disconnected (Internal Error)", _ => "Status: Disconnected (unknown fcs state, this is unreachable and a bug)" }; if (status == Backend.FirebaseConnectionStatus.Connected) return; // if we're not connected, hide any online 'features' ui.UI.Q<Button>("LeaderboardButton").style.display = DisplayStyle.None; ui.UI.Q<Button>("AccountButton").style.display = DisplayStyle.None; }); // register a callback to refresh the ui when the player signs in Backend.RegisterOnSignInCallback(_ => { Debug.Log("sign in callback, refreshing GameManager-controlled SideView UI"); _data.LoadFromTheWorld(FireLocalPlayerDataChangeCallbacks); }); Backend.RegisterOnConnectionStatusChangedCallback(status => { Debug.Log($"post-fcStatus change, deciding to show/hide buttons based on new status: {status}"); ui.UI.Q<Button>("LeaderboardButton").style.display = status == Backend.FirebaseConnectionStatus.Connected ? DisplayStyle.Flex : DisplayStyle.None; ui.UI.Q<Button>("AccountButton").style.display = status == Backend.FirebaseConnectionStatus.Connected ? DisplayStyle.Flex : DisplayStyle.None; }); Gameplay = new Gameplay(ui.UI); } /// <summary> /// called when the application is quitting, saves the local player data /// </summary> private void OnApplicationQuit() { Debug.Log("running deferred cleanup/save functions"); Backend.Cleanup(); _data.SaveToTheWorld(); } /// <summary> /// function to register a callback to be called when the local player data changes /// </summary> /// <param name="callback">callback function that takes a <c>LocalPlayerData</c> object</param> public void RegisterOnLocalPlayerDataChangeCallback(Action<LocalPlayerData> callback) { _onLocalPlayerDataChangeCallbacks.Add(callback); Debug.Log($"registering LocalPlayerDataChangeCallback ({_onLocalPlayerDataChangeCallbacks.Count})"); } /// <summary> /// function to fire all local player data change callbacks /// </summary> public void FireLocalPlayerDataChangeCallbacks(LocalPlayerData data) { Debug.Log($"firing LocalPlayerDataChangeCallbacks ({_onLocalPlayerDataChangeCallbacks.Count})"); foreach (var callback in _onLocalPlayerDataChangeCallbacks) try { callback.Invoke(data); } catch (Exception e) { Debug.LogError($"error invoking LocalPlayerDataChangeCallback: {e.Message}"); } } public void SignalGameEnd(List<Gameplay.RoundInfo> playedRounds) { Debug.Log("signalling game end"); // calculate historical averages var historicalLightnessAcc = 0f; var historicalChromaAcc = 0f; var historicalHueAcc = 0f; var historicalRounds = 0; foreach (var localScore in _data.RecentLocalScores) { historicalLightnessAcc += localScore.AvgLightnessAccuracy; historicalChromaAcc += localScore.AvgChromaAccuracy; historicalHueAcc += localScore.AvgHueAccuracy; historicalRounds += localScore.NoOfRounds; } foreach (var onlineScore in _data.RecentOnlineScores) { historicalLightnessAcc += onlineScore.AvgLightnessAccuracy; historicalChromaAcc += onlineScore.AvgChromaAccuracy; historicalHueAcc += onlineScore.AvgHueAccuracy; historicalRounds += onlineScore.NoOfRounds; } foreach (var onlineScore in _data.BestOnlineScores) { historicalLightnessAcc += onlineScore.AvgLightnessAccuracy; historicalChromaAcc += onlineScore.AvgChromaAccuracy; historicalHueAcc += onlineScore.AvgHueAccuracy; historicalRounds += onlineScore.NoOfRounds; } historicalLightnessAcc /= historicalRounds; historicalChromaAcc /= historicalRounds; historicalHueAcc /= historicalRounds; // calculate round averages var gameLightnessAcc = 0d; var gameChromaAcc = 0d; var gameHueAcc = 0d; var gamePerceivedAcc = 0d; var templateColour = Color.clear; var responseColour = Color.clear; var roundNumber = 1; foreach (var distance in playedRounds.Take(Gameplay.RoundsPerGame).Select(round => Colorimetry.CalculateDistance(templateColour = round.TemplateColour, responseColour = round.ResponseColour))) { var dLCh = Colorimetry.CalculateLChSimilarityPercentage(distance); Debug.Log( $"processing round: template={templateColour}, response={responseColour} (dL%={dLCh.L}, dC%={dLCh.C}, dh%={dLCh.h}, dEok={distance.dE:F})"); var roundLightnessAcc = Math.Clamp(dLCh.L * 100d, 0d, 100d); var roundChromaAcc = Math.Clamp(dLCh.C * 100d, 0d, 100d); var roundHueAcc = Math.Clamp(dLCh.h * 100d, 0d, 100d); var roundPerceivedAcc = Math.Clamp((100d - distance.dE) * 100d, 0d, 100d); gameLightnessAcc += roundLightnessAcc; gameChromaAcc += roundChromaAcc; gameHueAcc += roundHueAcc; gamePerceivedAcc += roundPerceivedAcc; var showcaseTemplate = ui.UI.Q<VisualElement>($"ShowcasePair{roundNumber}TemplateColour"); var showcaseResponse = ui.UI.Q<VisualElement>($"ShowcasePair{roundNumber}ResponseColour"); var showcaseInfo = ui.UI.Q<Label>($"ShowcasePair{roundNumber}Info"); if (!(showcaseTemplate == null || showcaseResponse == null || showcaseInfo == null)) { showcaseTemplate.style.backgroundColor = templateColour; showcaseResponse.style.backgroundColor = responseColour; showcaseInfo.text = $"{roundLightnessAcc:N0}% {roundChromaAcc:N0}% {roundHueAcc:N0}% ({roundPerceivedAcc:N0}%)"; } else { Debug.LogError($"showcase pair {roundNumber} not found"); } roundNumber++; // used for ui querying } gameLightnessAcc /= Gameplay.RoundsPerGame; gameChromaAcc /= Gameplay.RoundsPerGame; gameHueAcc /= Gameplay.RoundsPerGame; gamePerceivedAcc /= Gameplay.RoundsPerGame; // NOTE: this is NOT equiv to user rating, this is just a per-game accuracy score // all that math is done in LocalPlayerData.CalculateUserRating var gameAccuracy = (gameLightnessAcc + gameChromaAcc + gameHueAcc + gamePerceivedAcc) / 4; // make comparison texts var lAccDeltaText = (gameLightnessAcc > historicalLightnessAcc ? "+" : "-") + Math.Abs(gameLightnessAcc - historicalLightnessAcc).ToString("F2"); var cAccDeltaText = (gameChromaAcc > historicalChromaAcc ? "+" : "-") + Math.Abs(gameChromaAcc - historicalChromaAcc).ToString("F2"); var hAccDeltaText = (gameHueAcc > historicalHueAcc ? "+" : "-") + Math.Abs(gameHueAcc - historicalHueAcc).ToString("F2"); var score = new LocalPlayerData.Score(DateTime.Now, playedRounds.Count, (float)gameLightnessAcc, (float)gameChromaAcc, (float)gameHueAcc, (float)gamePerceivedAcc); var oldRating = _data.CalculateUserRating(); _data.RegisterLocalScore(score); FireLocalPlayerDataChangeCallbacks(Instance.Data); Debug.Log("submitting score to backend"); Backend.SubmitScore(score, submitRes => { if (submitRes != Backend.DatabaseTransactionResult.Ok) { Debug.Log("couldn't submit score"); TransitionToResultsView(_data.CalculateUserRating()); return; } Backend.CalculateUserRating((urcRes, userRating) => { if (urcRes != Backend.DatabaseTransactionResult.Ok) { Debug.Log("couldn't calculate user rating"); TransitionToResultsView(_data.CalculateUserRating()); FireLocalPlayerDataChangeCallbacks(Instance.Data); return; } Backend.UpdateUserRating(updateRes => { if (updateRes != Backend.DatabaseTransactionResult.Ok) { Debug.Log("calculated user rating but couldn't update it"); TransitionToResultsView(userRating); return; } TransitionToResultsView(userRating); }); }); }); return; void TransitionToResultsView(float rating) { Debug.Log("signal GameManager-UIManager transition to results view"); var ratingDifferenceDescriptor = oldRating > rating ? "decreased" : "increased"; var ratingText = rating >= 0 ? $"\nYour rating has {ratingDifferenceDescriptor} by {Math.Abs(rating - oldRating):F2}." : "\nYour rating could not be calculated."; // build the result text and show the results view ui.UI.Q<Label>("ResultsText").text = string.Join(Environment.NewLine, $"Over {playedRounds.Count} rounds,", $"you were {gameAccuracy} accurate.", "", $"Lightness was {gameLightnessAcc:F2}% accurate. ({lAccDeltaText} from your average)", $"Chroma was {gameChromaAcc:F2}% accurate. ({cAccDeltaText} from your average)", $"Hue was {gameHueAcc:F2}% accurate. ({hAccDeltaText} from your average)") + ratingText; ui.SetDisplayState(UIManager.DisplayState.ResultsView); } } }