From 9cc96fdcc62a61ba852be00dccbbc47f84029f47 Mon Sep 17 00:00:00 2001 From: Mark Joshwel Date: Wed, 27 Nov 2024 01:31:47 +0800 Subject: [PATCH] game(scripts): add backend and adjacent scripts --- Game/Assets/Scripts.meta | 8 + Game/Assets/Scripts/Backend.cs | 690 ++++++++++++++++++++ Game/Assets/Scripts/Backend.cs.meta | 11 + Game/Assets/Scripts/GameManager.cs | 121 ++++ Game/Assets/Scripts/GameManager.cs.meta | 3 + Game/Assets/Scripts/Leaderboard.cs | 32 + Game/Assets/Scripts/Leaderboard.cs.meta | 3 + Game/Assets/Scripts/LocalPlayerData.cs | 123 ++++ Game/Assets/Scripts/LocalPlayerData.cs.meta | 3 + 9 files changed, 994 insertions(+) create mode 100644 Game/Assets/Scripts.meta create mode 100644 Game/Assets/Scripts/Backend.cs create mode 100644 Game/Assets/Scripts/Backend.cs.meta create mode 100644 Game/Assets/Scripts/GameManager.cs create mode 100644 Game/Assets/Scripts/GameManager.cs.meta create mode 100644 Game/Assets/Scripts/Leaderboard.cs create mode 100644 Game/Assets/Scripts/Leaderboard.cs.meta create mode 100644 Game/Assets/Scripts/LocalPlayerData.cs create mode 100644 Game/Assets/Scripts/LocalPlayerData.cs.meta diff --git a/Game/Assets/Scripts.meta b/Game/Assets/Scripts.meta new file mode 100644 index 0000000..04830a9 --- /dev/null +++ b/Game/Assets/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2fa2ae79460cd147a2f52ae9879eb9f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Game/Assets/Scripts/Backend.cs b/Game/Assets/Scripts/Backend.cs new file mode 100644 index 0000000..c728297 --- /dev/null +++ b/Game/Assets/Scripts/Backend.cs @@ -0,0 +1,690 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Firebase; +using Firebase.Auth; +using Firebase.Database; +using Firebase.Extensions; +using UnityEngine; + +/// +/// the general managing class for handling communication with the firebase backend +/// (to be initialised by GameManager) +/// +public class Backend +{ + /// + /// enum for the result of the authentication process + /// + public enum AuthenticationResult + { + Ok, + AlreadyAuthenticated, + NonExistentUser, + AlreadyExistingUser, + UsernameAlreadyTaken, + InvalidEmail, + InvalidCredentials, + GenericError + } + + /// + /// enum for the connection status of the firebase backend + /// + public enum FirebaseConnectionStatus + { + NotConnected, + Connected, + UpdateRequired, // "a required system component is out of date" + Updating, // "a required system component is updating, retrying in a bit..." + ExternalError, // "a system component is disabled, invalid, missing, or permissions are insufficient" + InternalError // "an unknown error occurred" + } + + /// + /// generic enum for the result of a database transaction + /// + public enum TransactionResult + { + Ok, + Unauthenticated, + Error + } + + public enum UserAccountDetailTargetEnum + { + Username, + Email, + Password + } + + /// + /// callback functions to be invoked when the connection status changes + /// + /// + private readonly List> _onConnectionStatusChangedCallbacks = new(); + + /// + /// callback functions to be invoked when the user signs in + /// + private readonly List> _onSignInCallbacks = new(); + + /// + /// callback functions to be invoked when the user signs out + /// + private readonly List _onSignOutCallbacks = new(); + + /// + /// the firebase authentication object + /// + private FirebaseAuth _auth; + + /// + /// the firebase database reference + /// + private DatabaseReference _db; + + /// + /// the current user object, if authenticated + /// + private FirebaseUser _user; + + /// + /// the current user's username, if authenticated + /// + private string _username; + + /// + /// whether the user is signed in + /// + public bool IsSignedIn; + + /// + /// whether the backend is connected to the firebase backend + /// + public FirebaseConnectionStatus Status = FirebaseConnectionStatus.NotConnected; + + /// + /// variable initialisation function + /// + public void Initialise(Action callback) + { + FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task => + { + // cher is this robust enough + switch (task.Result) + { + case DependencyStatus.Available: + _auth = FirebaseAuth.GetAuth(FirebaseApp.DefaultInstance); + _auth.StateChanged += AuthStateChanged; + _db = FirebaseDatabase.DefaultInstance.RootReference; + Status = FirebaseConnectionStatus.Connected; + callback(Status); + FireOnConnectionStatusChangedCallbacks(); + break; + + case DependencyStatus.UnavailableDisabled: + case DependencyStatus.UnavailableInvalid: + case DependencyStatus.UnavilableMissing: + case DependencyStatus.UnavailablePermission: + Status = FirebaseConnectionStatus.ExternalError; + callback(Status); + FireOnConnectionStatusChangedCallbacks(); + break; + + case DependencyStatus.UnavailableUpdating: + Status = FirebaseConnectionStatus.Updating; + callback(Status); + FireOnConnectionStatusChangedCallbacks(); + RetryInitialiseAfterDelay(callback); + break; + + case DependencyStatus.UnavailableUpdaterequired: + Status = FirebaseConnectionStatus.UpdateRequired; + FireOnConnectionStatusChangedCallbacks(); + callback(Status); + break; + + case DependencyStatus.UnavailableOther: + default: + Status = FirebaseConnectionStatus.InternalError; + Debug.LogError("firebase ??? blew up or something," + task.Result); + FireOnConnectionStatusChangedCallbacks(); + callback(Status); + break; + } + + Debug.Log("firebase status is" + Status); + }); + } + + /// + /// async function to retry initialisation after a delay + /// + private async void RetryInitialiseAfterDelay(Action callback) + { + await Task.Delay(TimeSpan.FromSeconds(10)); + Initialise(callback); + } + + /// + /// cleanup function + /// + public void Cleanup() + { + SignOutUser(); + _auth.StateChanged -= AuthStateChanged; + _auth = null; + } + + /// + /// function to register a callback for when the user signs in + /// + /// callback function that takes in a FirebaseUser object + public void RegisterOnSignInCallback(Action callback) + { + _onSignInCallbacks.Add(callback); + Debug.Log($"registering OnSignInCallback ({_onSignInCallbacks.Count})"); + } + + /// + /// function to register a callback for when the user signs out + /// + /// callback function + public void RegisterOnSignOutCallback(Action callback) + { + _onSignOutCallbacks.Add(callback); + Debug.Log($"registering OnSignOutCallback ({_onSignOutCallbacks.Count})"); + } + + /// + /// function to register a callback for when the connection status changes + /// + /// callback function that takes in a FirebaseConnectionStatus enum + public void RegisterOnConnectionStatusChangedCallback(Action callback) + { + _onConnectionStatusChangedCallbacks.Add(callback); + Debug.Log($"registering ConnectionStatusChangedCallback ({_onConnectionStatusChangedCallbacks.Count})"); + } + + /// + /// function to fire all on sign in callbacks + /// + private void FireOnSignInCallbacks() + { + Debug.Log($"firing OnSignInCallbacks ({_onSignInCallbacks.Count})"); + foreach (var callback in _onSignInCallbacks) + try + { + callback.Invoke(_user); + } + catch (Exception e) + { + Debug.LogError($"error invoking OnSignInCallback: {e.Message}"); + } + } + + /// + /// function to fire all on sign-out callbacks + /// + private void FireOnSignOutCallbacks() + { + Debug.Log($"firing OnSignOutCallbacks ({_onSignOutCallbacks.Count})"); + foreach (var callback in _onSignOutCallbacks) + try + { + callback.Invoke(); + } + catch (Exception e) + { + Debug.LogError($"error invoking OnSignOutCallback: {e.Message}"); + } + } + + /// + /// function to fire all on connection status changed callbacks + /// + private void FireOnConnectionStatusChangedCallbacks() + { + Debug.Log($"firing OnConnectionStatusChangedCallbacks ({_onConnectionStatusChangedCallbacks.Count})"); + foreach (var callback in _onConnectionStatusChangedCallbacks) + try + { + callback.Invoke(Status); + } + catch (Exception e) + { + Debug.LogError($"error invoking OnConnectionStatusChangedCallback: {e.Message}"); + } + } + + /// + /// function to handle the authentication state change event + /// + /// the object that triggered the event + /// the event arguments + private void AuthStateChanged(object sender, EventArgs eventArgs) + { + // if the user hasn't changed, do nothing + if (_auth.CurrentUser == _user) return; + + // if the user has changed, check if they've signed in or out + IsSignedIn = _user != _auth.CurrentUser && _auth.CurrentUser != null; + + // if we're not signed in, but we still hold _user locally, we've signed out + if (!IsSignedIn && _user != null) + { + Debug.Log("moi-moi"); + FireOnSignOutCallbacks(); + } + + // they have signed in, update _user + _user = _auth.CurrentUser; + if (!IsSignedIn) return; + + Debug.Log($"signed in successfully as {_user?.UserId}"); + RetrieveUsernameWithCallback((_, _) => { FireOnSignInCallbacks(); }); + } + + /// + /// abstraction function to authenticate the user + /// + /// email string + /// user raw password string + /// callback function that takes in an AuthenticationResult enum + /// whether to treat authentication as registration + /// username string if registering + public void AuthenticateUser( + string email, + string password, + Action callback, + bool registerUser = false, + string registeringUsername = "") + { + if (GameManager.Instance.Backend.GetUser() != null) + { + callback(AuthenticationResult.AlreadyAuthenticated); + return; + } + + if (registerUser) + { + // check if the username is already taken + _db.Child("users") + .OrderByChild("username") + .EqualTo(registeringUsername) + .GetValueAsync() + .ContinueWithOnMainThread(task => + { + if (task.Exception != null) + { + Debug.LogError(task.Exception); + callback(AuthenticationResult.GenericError); + return; + } + + if (!task.IsCompletedSuccessfully || task.Result.ChildrenCount > 0) + { + callback(AuthenticationResult.UsernameAlreadyTaken); + return; + } + + // register user + _auth.CreateUserWithEmailAndPasswordAsync(email, password) + .ContinueWithOnMainThread(createTask => + { + if (createTask.IsCompletedSuccessfully) + { + // store username + _db.Child("users") + .Child(_user.UserId) + .Child("username") + .SetValueAsync(registeringUsername) + .ContinueWithOnMainThread(setUsernameTask => + { + if (setUsernameTask.IsCompletedSuccessfully) + { + _username = registeringUsername; + callback(AuthenticationResult.Ok); + } + else + { + Debug.LogError(setUsernameTask.Exception); + callback(AuthenticationResult.GenericError); + } + }); + return; + } + + if (createTask.Exception?.InnerException == null) + { + callback(AuthenticationResult.GenericError); + return; + } + + var error = (AuthError)((FirebaseException)createTask.Exception.InnerException).ErrorCode; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (error) + { + case AuthError.UserNotFound: + callback(AuthenticationResult.NonExistentUser); + return; + + case AuthError.InvalidEmail: + callback(AuthenticationResult.InvalidEmail); + return; + + case AuthError.WeakPassword: + case AuthError.InvalidCredential: + callback(AuthenticationResult.InvalidCredentials); + return; + + case AuthError.AccountExistsWithDifferentCredentials: + case AuthError.EmailAlreadyInUse: + callback(AuthenticationResult.AlreadyExistingUser); + return; + + case AuthError.Failure: + default: + Debug.LogError(error); + Debug.LogError(createTask.Exception); + callback(AuthenticationResult.GenericError); + break; + } + }); + }); + return; + } + + // logging in + _auth.SignInWithEmailAndPasswordAsync(email, password) + .ContinueWithOnMainThread(signInTask => + { + if (signInTask.IsCompletedSuccessfully) + { + RetrieveUsername(); + callback(AuthenticationResult.Ok); + return; + } + + if (signInTask.Exception?.InnerException == null) + { + callback(AuthenticationResult.GenericError); + return; + } + + var error = (AuthError)((FirebaseException)signInTask.Exception.InnerException).ErrorCode; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (error) + { + case AuthError.UserNotFound: + callback(AuthenticationResult.NonExistentUser); + return; + case AuthError.InvalidEmail: + callback(AuthenticationResult.InvalidEmail); + return; + case AuthError.WeakPassword: + case AuthError.InvalidCredential: + callback(AuthenticationResult.InvalidCredentials); + return; + case AuthError.AccountExistsWithDifferentCredentials: + case AuthError.EmailAlreadyInUse: + callback(AuthenticationResult.AlreadyExistingUser); + return; + case AuthError.Failure: + default: + Debug.LogError(error); + Debug.LogError(signInTask.Exception); + callback(AuthenticationResult.GenericError); + break; + } + }); + } + + /// + /// helper function to run RetrieveUsername with no callback + /// + private void RetrieveUsername() + { + RetrieveUsernameWithCallback((_, _) => { }); + } + + /// + /// function to retrieve the user's username from the database + /// + private void RetrieveUsernameWithCallback(Action callback) + { + if (!Status.Equals(FirebaseConnectionStatus.Connected)) return; + + if (_user == null) + { + Debug.LogError("receiving username post-authentication but user is null (should be unreachable)"); + callback(TransactionResult.Unauthenticated, "Unknown"); + return; + } + + _db.Child("users") + .Child(_user.UserId) + .Child("username") + .GetValueAsync() + .ContinueWithOnMainThread(task => + { + TransactionResult result; + if (task.IsCompletedSuccessfully) + { + result = TransactionResult.Ok; + _username = task.Result.Value.ToString(); + Debug.Log($"our username is {_username}"); + } + else + { + result = TransactionResult.Error; + _username = "Unknown"; + Debug.LogError("failed to get username"); + } + + callback(result, _username); + }); + } + + /// + /// abstraction function to retrieve the user + /// + /// the firebase user object + public FirebaseUser GetUser() + { + return _user; + } + + public string GetUsername() + { + return _username; + } + + /// + /// abstraction function to sign out the user + /// + public void SignOutUser() + { + _auth.SignOut(); + } + + /// + /// abstraction function to delete the user + /// + public void DeleteUser(Action callback) + { + if (!Status.Equals(FirebaseConnectionStatus.Connected)) + { + callback(TransactionResult.Error); + return; + } + + if (_user == null) + { + callback(TransactionResult.Unauthenticated); + return; + } + + _user.DeleteAsync().ContinueWithOnMainThread(task => + { + if (task.IsCompletedSuccessfully) + { + Debug.Log("user deleted"); + _user = null; + FireOnSignOutCallbacks(); + callback(TransactionResult.Ok); + } + else + { + Debug.LogError($"error deleting user: {task.Exception}"); + callback(TransactionResult.Error); + } + }); + } + + /// + /// abstraction function for the user to reset their password + /// + /// the forgetful user's email lol + /// callback function to be invoked after the password reset email is sent + public void ResetUserPassword(string email, Action callback) + { + _auth.SendPasswordResetEmailAsync(email) + .ContinueWithOnMainThread(resetTask => + { + if (resetTask.IsCompletedSuccessfully) + { + callback(true); + } + else + { + Debug.LogError(resetTask.Exception); + callback(false); + } + }); + } + + /// + /// abstraction function to get the leaderboard from the database + /// + /// + /// callback function that takes in a TransactionResult enum and a List<LeaderboardEntry> + /// + public void GetLeaderboard( + Action> callback) + { + Debug.Log("getting leaderboard"); + + _db.Child("users") + .OrderByChild("rating") + .LimitToLast(Leaderboard.MaxEntries) + .GetValueAsync() + .ContinueWithOnMainThread(task => + { + if (!task.IsCompletedSuccessfully) + { + Debug.LogError(task.Exception); + callback(TransactionResult.Error, new List(0)); + return; + } + + var entries = new List(); + foreach (var child in task.Result.Children) + try + { + var entry = new LeaderboardEntry(child.Value as Dictionary); + entries.Add(entry); + } + catch (Exception e) + { + Debug.LogError(e); + } + + callback(TransactionResult.Ok, entries); + }); + } + + /// + /// abstraction function to update the user's account details in the database + /// + /// the target account detail to update + /// the new value for the target account detail + /// callback function that takes in a TransactionResult enum + /// thrown when the target is not a valid UserAccountDetailTargetEnum + public void UpdateUserAccountDetail( + UserAccountDetailTargetEnum target, + string newValue, + Action callback) + { + if (!Status.Equals(FirebaseConnectionStatus.Connected)) callback(TransactionResult.Unauthenticated); + + if (_user == null) + { + callback(TransactionResult.Unauthenticated); + return; + } + + switch (target) + { + case UserAccountDetailTargetEnum.Email: + _user.SendEmailVerificationBeforeUpdatingEmailAsync(newValue).ContinueWithOnMainThread(task => + { + if (task.IsCompletedSuccessfully) + { + GameManager.Instance.Data.LastKnownEmail = newValue; + GameManager.Instance.FireLocalPlayerDataChangeCallbacks(GameManager.Instance.Data); + callback(TransactionResult.Ok); + } + else + { + Debug.LogError(task.Exception); + callback(TransactionResult.Error); + } + }); + break; + + case UserAccountDetailTargetEnum.Username: + _db.Child("users") + .Child(_user.UserId) + .Child("username") + .SetValueAsync(newValue) + .ContinueWithOnMainThread(task => + { + if (task.IsCompletedSuccessfully) + { + _username = newValue; + GameManager.Instance.Data.LastKnownUsername = newValue; + GameManager.Instance.FireLocalPlayerDataChangeCallbacks(GameManager.Instance.Data); + callback(TransactionResult.Ok); + } + else + { + Debug.LogError(task.Exception); + callback(TransactionResult.Error); + } + }); + break; + + case UserAccountDetailTargetEnum.Password: + _user.UpdatePasswordAsync(newValue).ContinueWithOnMainThread(task => + { + if (task.IsCompletedSuccessfully) + { + callback(TransactionResult.Ok); + } + else + { + Debug.LogError(task.Exception); + callback(TransactionResult.Error); + } + }); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(target), target, null); + } + } +} \ No newline at end of file diff --git a/Game/Assets/Scripts/Backend.cs.meta b/Game/Assets/Scripts/Backend.cs.meta new file mode 100644 index 0000000..da91118 --- /dev/null +++ b/Game/Assets/Scripts/Backend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d4abe93509ef91f47a2e59dd474a7137 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Game/Assets/Scripts/GameManager.cs b/Game/Assets/Scripts/GameManager.cs new file mode 100644 index 0000000..6c1f18e --- /dev/null +++ b/Game/Assets/Scripts/GameManager.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// singleton for a single source of truth game state and flow management +/// +public class GameManager : MonoBehaviour +{ + /// + /// singleton pattern: define instance field for accessing the singleton elsewhere + /// + public static GameManager Instance; + + /// + /// list of callbacks to call when the local player data changes + /// + private readonly List> _onLocalPlayerDataChangeCallbacks = new(); + + /// + /// the local player data object for storing player data + /// + private LocalPlayerData _data; + + /// + /// backend object for handling communication with the firebase backend + /// + public Backend Backend; + + /// + /// read-only property for accessing the local player data outside this class + /// + public LocalPlayerData Data => _data; + + /// + /// enforces singleton behaviour; sets doesn't destroy on load and checks for multiple instances + /// + 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); + } + } + + /// + /// start modifying state + /// + private void Start() + { + Debug.Log("GameManager starts here"); + _data.LoadFromTheWorld(FireLocalPlayerDataChangeCallbacks); + } + + /// + /// initialise variables and ui elements + /// + private void OnEnable() + { + // load the local player data and refresh the ui + _data = new LocalPlayerData(); + + Backend = new Backend(); + Backend.Initialise(status => { Debug.Log("initialised backend"); }); + + // 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: {status}"); }); + } + + /// + /// called when the application is quitting, saves the local player data + /// + private void OnApplicationQuit() + { + Debug.Log("running deferred cleanup/save functions"); + Backend.Cleanup(); + _data.SaveToTheWorld(); + } + + /// + /// function to register a callback to be called when the local player data changes + /// + /// callback function that takes a LocalPlayerData object + public void RegisterOnLocalPlayerDataChangeCallback(Action callback) + { + _onLocalPlayerDataChangeCallbacks.Add(callback); + Debug.Log($"registering LocalPlayerDataChangeCallback ({_onLocalPlayerDataChangeCallbacks.Count})"); + } + + /// + /// function to fire all local player data change callbacks + /// + 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}"); + } + } +} \ No newline at end of file diff --git a/Game/Assets/Scripts/GameManager.cs.meta b/Game/Assets/Scripts/GameManager.cs.meta new file mode 100644 index 0000000..b2de2ba --- /dev/null +++ b/Game/Assets/Scripts/GameManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: deaef88acf07406db5e0c354472ae40c +timeCreated: 1732641033 \ No newline at end of file diff --git a/Game/Assets/Scripts/Leaderboard.cs b/Game/Assets/Scripts/Leaderboard.cs new file mode 100644 index 0000000..eee26d5 --- /dev/null +++ b/Game/Assets/Scripts/Leaderboard.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// class that loads leaderboard data and displays it in the UI +/// +public class Leaderboard : MonoBehaviour +{ + /// + /// maximum number of entries to display in the leaderboard + /// + public const int MaxEntries = 50; + + // /// + // /// register callbacks + // /// + // private void OnEnable() {} +} + +public readonly struct LeaderboardEntry +{ + public readonly string Username; + + public LeaderboardEntry(Dictionary data) + { + if (!data.ContainsKey("username") || data["username"] is not string username) + throw new ArgumentException("data['username'] not found or invalid"); + + Username = username; + } +} \ No newline at end of file diff --git a/Game/Assets/Scripts/Leaderboard.cs.meta b/Game/Assets/Scripts/Leaderboard.cs.meta new file mode 100644 index 0000000..8e2edbb --- /dev/null +++ b/Game/Assets/Scripts/Leaderboard.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 827c6e446cfa4da886052fe3934bdc1f +timeCreated: 1732641719 \ No newline at end of file diff --git a/Game/Assets/Scripts/LocalPlayerData.cs b/Game/Assets/Scripts/LocalPlayerData.cs new file mode 100644 index 0000000..eb04ef1 --- /dev/null +++ b/Game/Assets/Scripts/LocalPlayerData.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// local player data structure/class +/// +public class LocalPlayerData +{ + /// + /// last known email used + /// + public string LastKnownEmail = ""; + + /// + /// last known username used + /// + public string LastKnownUsername = "Guest"; + + /// + /// 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; + + Debug.Log($"loaded lpdata from the local world ({LastKnownUsername} <{LastKnownEmail}>"); + callback(this); + + // TODO: potentially load data from the backend + } + + /// + /// saves player data to player prefs + /// + public void SaveToTheWorld() + { + PlayerPrefs.SetString("LastKnownEmail", LastKnownEmail); + PlayerPrefs.SetString("LastKnownUsername", LastKnownUsername); + // online scores should have already saved in the backend + Debug.Log("saved lpdata to player prefs"); + } + + // /// + // /// safely get a float value from a dictionary + // /// + // /// the dictionary to get the value from + // /// the key to get the value from + // /// the float value + // /// thrown if the key is not found, or the value is not a valid float + // public static float GetFloatyKey(Dictionary data, string key) + // { + // if (!data.TryGetValue(key, out var possibleFloat)) throw new ArgumentException($"{key} not found"); + // return possibleFloat switch + // { + // double f => (float)f, + // long l => l, + // _ => throw new ArgumentException($"data['{key}'] not a valid float") + // }; + // } + + public struct Score + { + /// + /// timestamp of the score + /// + public DateTime Timestamp; + + /// + /// constructor for the score struct + /// + /// timestamp of the score + public Score(DateTime timestamp = new()) + { + Timestamp = timestamp; + } + + /// + /// 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("data['timestamp'] not found or invalid"); + + Timestamp = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + } + + /// + /// converts the score struct to a dictionary safe for the backend + /// + /// + public Dictionary ToDictionary(string userId = "") + { + var dict = new Dictionary + { + { "timestamp", new DateTimeOffset(Timestamp).ToUnixTimeSeconds() } + }; + + if (!string.IsNullOrEmpty(userId)) dict.Add("userId", userId); + + return dict; + } + } +} \ No newline at end of file diff --git a/Game/Assets/Scripts/LocalPlayerData.cs.meta b/Game/Assets/Scripts/LocalPlayerData.cs.meta new file mode 100644 index 0000000..8aed690 --- /dev/null +++ b/Game/Assets/Scripts/LocalPlayerData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 51a8ca9c59474177bbfaab42d698cd3c +timeCreated: 1732641111 \ No newline at end of file