game: interim

This commit is contained in:
Mark Joshwel 2024-11-17 19:10:01 +08:00
parent 0d57d2b3a9
commit c5402178f0
14 changed files with 905 additions and 461 deletions

View file

@ -134,6 +134,7 @@ GameObject:
- component: {fileID: 133964671}
- component: {fileID: 133964673}
- component: {fileID: 133964674}
- component: {fileID: 133964675}
m_Layer: 5
m_Name: UI
m_TagString: Untagged
@ -200,6 +201,18 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
state: 0
--- !u!114 &133964675
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 133964670}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6351b7620d84e2d43bc4f59c5f3f8b5c, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &447905425
GameObject:
m_ObjectHideFlags: 0

View file

@ -1,70 +1,61 @@
using System;
using System.Net.Mail;
using Firebase.Auth;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.UIElements;
using Button = UnityEngine.UIElements.Button;
/// <summary>
/// class to handle the account view ui
/// </summary>
public class AccountUI : MonoBehaviour
{
/// <summary>
/// state of the account view
/// </summary>
private enum State
{
UnassociatedState, // (start)
NotSignedIn, // initial
AfterContinue, // after
SignedIn // post
}
/// <summary>
/// current state of the account view
/// </summary>
[SerializeField] private State state = State.UnassociatedState;
/// <summary>
/// account view header text
/// default text colour
/// </summary>
private Label _header;
private readonly Color _defaultInputFieldValueTextColour = new(5.88f, 5.1f, 10.59f);
/// <summary>
/// username text field
/// error text colour
/// </summary>
private TextField _usernameField;
private readonly Color _errorInputFieldValueTextColour = new(1f, 50.59f, 50.2f);
/// <summary>
/// accompanying text for the account input fields, used when an error/notice is needed
/// </summary>
private Label _accompanyingText;
/// <summary>
/// email text field
/// </summary>
private TextField _emailField;
/// <summary>
/// password text field
/// </summary>
private TextField _passwordField;
/// <summary>
/// button to update the username
/// </summary>
private Button _usernameUpdateButton;
/// <summary>
/// button to update the email
/// </summary>
private Button _emailUpdateButton;
/// <summary>
/// account view header text
/// </summary>
private Label _header;
/// <summary>
/// password text field
/// </summary>
private TextField _passwordField;
/// <summary>
/// button to update the password
/// </summary>
private Button _passwordUpdateButton;
/// <summary>
/// accompanying text for the account input fields, used when an error/notice is needed
/// </summary>
private Label _accompanyingText;
/// <summary>
/// either 'continue', 'log in', or 'sign out' button
/// (in order of 'initial', 'after', and 'post' states)
@ -77,6 +68,23 @@ private enum State
/// </summary>
private Button _secondaryActionButton;
/// <summary>
/// username text field
/// </summary>
private TextField _usernameField;
/// <summary>
/// button to update the username
/// </summary>
private Button _usernameUpdateButton;
public void Start()
{
if (state == State.UnassociatedState) throw new Exception("unreachable state");
// GameManager.Instance.Backend.RegisterOnSignInCallback(OnSignInCallback);
}
/// <summary>
/// function to subscribe button events to their respective functions
/// </summary>
@ -108,14 +116,27 @@ public void OnEnable()
_secondaryActionButton.clicked += OnSecondaryActionButtonClick;
TransitionStateTo(State.NotSignedIn);
GameManager.Instance.Backend.RegisterOnSignInCallback(OnSignInCallback);
}
public void Start()
private void OnSignInCallback(FirebaseUser user)
{
if (state == State.UnassociatedState)
{
throw new Exception("unreachable state");
}
Debug.Log("sign in account ui callback");
var username = GameManager.Instance.Backend.GetUsername();
_usernameField.value = username;
_emailField.value = GameManager.Instance.Backend.GetUser().Email;
_passwordField.value = "";
_header.text = $"Signed in as {username}";
}
/// <summary>
/// populate the fields with the given username and email, used by GameManager after local player data is loaded
/// </summary>
public void PopulateFields(string username, string email)
{
_usernameField.value = username;
_emailField.value = email;
}
private void TransitionStateTo(State newState, bool keepAccompanyingText = false)
@ -140,8 +161,6 @@ private void TransitionStateTo(State newState, bool keepAccompanyingText = false
_emailUpdateButton.style.display = DisplayStyle.Flex;
}
Debug.Log($"transitioning to {newState}");
// set primary/secondary buttons
switch (newState)
{
@ -174,6 +193,8 @@ private void TransitionStateTo(State newState, bool keepAccompanyingText = false
break;
case State.SignedIn:
Debug.Log("transitioning to signed in state");
var username = GameManager.Instance.Backend.GetUsername();
_header.text = string.IsNullOrEmpty(username) ? "You are signed in." : $"Signed in as {username}";
@ -181,10 +202,6 @@ private void TransitionStateTo(State newState, bool keepAccompanyingText = false
_emailField.style.display = DisplayStyle.Flex;
_passwordField.style.display = DisplayStyle.Flex;
_usernameField.value = GameManager.Instance.Backend.GetUsername();
_emailField.value = GameManager.Instance.Backend.GetUser().Email;
_passwordField.value = "";
_primaryActionButton.style.display = DisplayStyle.Flex;
_secondaryActionButton.style.display = DisplayStyle.None;
@ -199,18 +216,23 @@ private void TransitionStateTo(State newState, bool keepAccompanyingText = false
state = newState;
}
private void ValidateUsername()
{
// just has to be min. 5 characters
_usernameField.style.color = _defaultInputFieldValueTextColour;
if (_usernameField.value.Length >= 5) return;
_usernameField.style.color = _errorInputFieldValueTextColour;
throw new Exception("Username must be at least 5 characters long.");
}
/// <summary>
/// validate the email and password fields
/// validate the email field
/// </summary>
/// <param name="emailField">the email field to validate</param>
/// <param name="passwordField">the password field to validate</param>
/// <exception cref="Exception">if the email or password fields are invalid</exception>
private void ValidateFields(TextField emailField, TextField passwordField)
/// <exception cref="Exception">if the email field is invalid</exception>
private void ValidateEmailField(TextField emailField)
{
emailField.style.color = Color.black;
passwordField.style.color = Color.black;
// validate email
emailField.style.color = _defaultInputFieldValueTextColour;
try
{
var dot = emailField.value.LastIndexOf(".", StringComparison.Ordinal);
@ -226,34 +248,217 @@ private void ValidateFields(TextField emailField, TextField passwordField)
}
catch
{
emailField.style.color = Color.red;
emailField.style.color = _errorInputFieldValueTextColour;
throw new Exception("Invalid email.");
}
// validate password
if (passwordField.value.Length >= 10) return;
passwordField.style.color = Color.red;
throw new Exception("Invalid password.");
}
/// <summary>
/// validate the password field
/// </summary>
/// <param name="passwordField">the password field to validate</param>
/// <exception cref="Exception">if the password field is invalid</exception>
private void ValidatePasswordField(TextField passwordField)
{
passwordField.style.color = _defaultInputFieldValueTextColour;
if (passwordField.value.Length >= 10) return;
passwordField.style.color = _errorInputFieldValueTextColour;
throw new Exception("Password must be at least 10 characters long.");
}
/// <summary>
/// validate both the email and password fields
/// </summary>
/// <param name="emailField">the email field to validate</param>
/// <param name="passwordField">the password field to validate</param>
/// <exception cref="Exception">if either the email or password field is invalid</exception>
private void ValidateFields(TextField emailField, TextField passwordField)
{
var invalidEmail = false;
var invalidPassword = false;
try
{
ValidateEmailField(_emailField);
}
catch (Exception _)
{
invalidEmail = true;
}
try
{
ValidatePasswordField(_passwordField);
}
catch (Exception _)
{
invalidPassword = true;
}
if (!invalidEmail && !invalidPassword) return;
var errorMessage = (invalidEmail, invalidPassword) switch
{
(true, true) => "Invalid email and password is too short.",
(true, false) => "Invalid email.",
(false, true) => "Passwords must be at least 10 characters long.",
_ => null
};
throw new Exception(errorMessage);
}
/// <summary>
/// validate both the email and password fields
/// </summary>
/// <param name="emailField">the email field to validate</param>
/// <param name="passwordField">the password field to validate</param>
/// <param name="usernameField">the username field to validate</param>
/// <exception cref="Exception">if either the email or password field is invalid</exception>
private void ValidateFields(TextField emailField, TextField passwordField, TextField usernameField)
{
var invalidEmail = false;
var invalidPassword = false;
var invalidUsername = false;
try
{
ValidateEmailField(_emailField);
}
catch (Exception _)
{
invalidEmail = true;
}
try
{
ValidatePasswordField(_passwordField);
}
catch (Exception _)
{
invalidPassword = true;
}
try
{
ValidateUsername();
}
catch (Exception _)
{
invalidUsername = true;
}
if (!invalidEmail && !invalidPassword && !invalidUsername) return;
var errorMessage = (invalidEmail, invalidPassword, invalidUsername) switch
{
(true, true, true) => "Invalid email, password is too short, and username is too short.",
(true, true, false) => "Invalid email, and password is too short.",
(true, false, true) => "Invalid email, and username is too short.",
(true, false, false) => "Invalid email.",
(false, true, true) => "Password is too short, and username is too short.",
(false, true, false) => "Password is too short.",
(false, false, true) => "Username is too short.",
_ => null
};
throw new Exception(errorMessage);
}
/// <summary>
/// function to handle the username update button click
/// </summary>
private void OnUsernameUpdateButtonClick()
{
// TODO
throw new NotImplementedException();
try
{
ValidateUsername();
}
catch (Exception e)
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = e.Message;
return;
}
GameManager.Instance.Backend.UpdateUserAccountDetail(Backend.UserAccountDetailTargetEnum.Username,
_usernameField.value,
dtr =>
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = dtr switch
{
Backend.DatabaseTransactionResult.Ok => "Username updated!",
Backend.DatabaseTransactionResult.Unauthenticated =>
"You are not signed in. Please sign in to update your username.",
_ => "An error occurred updating the username. Please try again."
};
});
}
/// <summary>
/// function to handle the email update button click
/// </summary>
private void OnEmailUpdateButtonClick()
{
// TODO
throw new NotImplementedException();
try
{
ValidateEmailField(_emailField);
}
catch (Exception e)
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = e.Message;
return;
}
GameManager.Instance.Backend.UpdateUserAccountDetail(Backend.UserAccountDetailTargetEnum.Email,
_emailField.value,
callback =>
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = callback switch
{
Backend.DatabaseTransactionResult.Ok =>
$"Verification email sent to {_emailField.value}! You may want to sign in again to see the changes.",
Backend.DatabaseTransactionResult.Unauthenticated =>
"You are not signed in. Please sign in to update your email.",
_ => "An error occurred updating the email. Please try again."
};
});
}
/// <summary>
/// function to handle the password update button click
/// </summary>
private void OnPasswordUpdateButtonClick()
{
// TODO
throw new NotImplementedException();
try
{
ValidatePasswordField(_passwordField);
}
catch (Exception e)
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = e.Message;
return;
}
GameManager.Instance.Backend.UpdateUserAccountDetail(Backend.UserAccountDetailTargetEnum.Password,
_passwordField.value,
callback =>
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = callback switch
{
Backend.DatabaseTransactionResult.Ok => "Password updated!",
Backend.DatabaseTransactionResult.Unauthenticated =>
"You are not signed in. Please sign in to update your password.",
_ => "An error occurred updating the password. Please try again."
};
});
}
/// <summary>
/// function to handle the primary action button click
/// </summary>
private void OnPrimaryActionButtonClick()
{
switch (state)
@ -277,7 +482,7 @@ private void OnPrimaryActionButtonClick()
GameManager.Instance.Backend.AuthenticateUser(
_emailField.value,
_passwordField.value,
(result) =>
result =>
{
switch (result)
{
@ -305,13 +510,13 @@ private void OnPrimaryActionButtonClick()
default:
TransitionStateTo(State.AfterContinue);
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = "Either the account does not exist, or there was an internal error. Try again.";
_accompanyingText.text =
"There was an error. Either the account does not exist, or the credentials are invalid. Try again.";
break;
}
});
break;
// sign out button
case State.SignedIn:
GameManager.Instance.Backend.SignOutUser();
@ -324,19 +529,41 @@ private void OnPrimaryActionButtonClick()
}
}
/// <summary>
/// function to handle the secondary action button click
/// </summary>
private void OnSecondaryActionButtonClick()
{
switch (state)
{
// forgot password button
case State.NotSignedIn:
throw new NotImplementedException();
try
{
ValidateEmailField(_emailField);
}
catch (Exception e)
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text = e.Message;
return;
}
GameManager.Instance.Backend.ForgotPassword(_emailField.value, result =>
{
_accompanyingText.style.display = DisplayStyle.Flex;
_accompanyingText.text =
result
? "Password reset email sent. Check your inbox! :D"
: "An error occurred sending the email. Please try again.";
});
break;
// create an account button
case State.AfterContinue:
try
{
ValidateFields(_emailField, _passwordField);
ValidateFields(_emailField, _passwordField, _usernameField);
}
catch (Exception e)
{
@ -357,7 +584,7 @@ private void OnSecondaryActionButtonClick()
GameManager.Instance.Backend.AuthenticateUser(
_emailField.value,
_passwordField.value,
(result) =>
result =>
{
switch (result)
{
@ -396,4 +623,15 @@ private void OnSecondaryActionButtonClick()
throw new ArgumentOutOfRangeException();
}
}
/// <summary>
/// state of the account view
/// </summary>
private enum State
{
UnassociatedState, // (start)
NotSignedIn, // initial
AfterContinue, // after
SignedIn // post
}
}

View file

@ -1,163 +0,0 @@
/*
* author: mark joshwel
* date: 29/5/2024
* description: audio manager for handling audio in the game
*/
using System;
using UnityEngine;
/// <summary>
/// singleton class for handling audio in the game
/// </summary>
public class AudioManager : MonoBehaviour
{
/// <summary>
/// enum for available audio channels in the game
/// </summary>
public enum AudioChannel
{
Music,
SoundEffects
}
/// <summary>
/// singleton pattern: define instance field for accessing the singleton elsewhere
/// </summary>
public static AudioManager Instance;
/// <summary>
/// music audio source
/// </summary>
[SerializeField] private AudioSource musicSource;
/// <summary>
/// sound effects (sfx) audio source
/// </summary>
[SerializeField] private AudioSource sfxSource;
/// <summary>
/// music source default volume
/// </summary>
[SerializeField] private float musicSourceDefaultVolume = 0.6f;
/// <summary>
/// sound effects (sfx) source default volume
/// </summary>
[SerializeField] private float sfxSourceDefaultVolume = 0.6f;
/// <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>
/// function to set default volumes for the audio sources
/// </summary>
public void Start()
{
// set the default volume for the music source
musicSource.volume = musicSourceDefaultVolume;
// set the default volume for the sfx source
sfxSource.volume = sfxSourceDefaultVolume;
}
/// <summary>
/// plays the audio clip once on a given channel
/// </summary>
/// <param name="clip">the audio clip to play</param>
/// <param name="channel">the audio channel to play the clip on</param>
public void PlayClipOnChannel(AudioClip clip, AudioChannel channel)
{
switch (channel)
{
case AudioChannel.Music:
musicSource.PlayOneShot(clip);
break;
case AudioChannel.SoundEffects:
sfxSource.PlayOneShot(clip);
break;
default:
Debug.LogError($"invalid channel '{channel}'");
break;
}
}
/// <summary>
/// gets the volume of a given audio channel
/// </summary>
/// <param name="channel">the AudioManager.AudioChannel to get the volume of</param>
/// <returns>volume float value of the channel, from 0.0f-1.0f</returns>
public float GetChannelVolume(AudioChannel channel)
{
switch (channel)
{
case AudioChannel.Music:
return musicSource.volume;
case AudioChannel.SoundEffects:
return sfxSource.volume;
default:
Debug.LogError($"invalid channel '{channel}'");
return 0f;
}
}
/// <summary>
/// sets the pure volume value of a given audio channel
/// </summary>
/// <param name="volume">volume float value for the channel, from 0.0f-1.0f</param>
/// <param name="channel">the AudioManager.AudioChannel to set the volume of</param>
public void SetChannelVolumeReal(float volume, AudioChannel channel)
{
switch (channel)
{
case AudioChannel.Music:
musicSource.volume = Math.Min(volume, 1.0f);
break;
case AudioChannel.SoundEffects:
sfxSource.volume = Math.Min(volume, 1.0f);
break;
default:
Debug.LogError($"invalid channel '{channel}'");
break;
}
}
/// <summary>
/// sets the volume value of a given audio channel based on a logarithmic scale
/// for human perception (e.g. 0.5f is half volume, 0.1f is very quiet)
/// </summary>
/// <param name="volume">volume float value for the channel, from 0.0f-1.0f</param>
/// <param name="channel">the AudioManager.AudioChannel to set the volume of</param>
public void SetChannelVolumeLog(float volume, AudioChannel channel)
{
switch (channel)
{
case AudioChannel.Music:
musicSource.volume = Mathf.Log10(Mathf.Max(volume, 0.0001f)) * 20;
break;
case AudioChannel.SoundEffects:
sfxSource.volume = Mathf.Log10(Mathf.Max(volume, 0.0001f)) * 20;
break;
default:
Debug.LogError($"invalid channel '{channel}'");
break;
}
}
}

View file

@ -50,6 +50,13 @@ public enum FirebaseConnectionStatus
InternalError // "an unknown error occurred"
}
public enum UserAccountDetailTargetEnum
{
Username,
Email,
Password
}
/// <summary>
/// callback functions to be invoked when the user signs in
/// </summary>
@ -171,12 +178,13 @@ private void AuthStateChanged(object sender, EventArgs eventArgs)
// they have signed in, update _user
_user = _auth.CurrentUser;
if (signedIn)
if (!signedIn) return;
Debug.Log($"signed in successfully as {_user.UserId}");
RetrieveUsernameWithCallback((_, _) =>
{
Debug.Log($"signed in successfully as {_user.UserId}");
RetrieveUsername();
foreach (var callback in _onSignInCallback) callback(_user);
}
});
}
/// <summary>
@ -292,6 +300,7 @@ public void RegisterOnSignOutCallback(Action<FirebaseUser> callback)
{
if (signInTask.IsCompletedSuccessfully)
{
RetrieveUsername();
callback(AuthenticationResult.Ok);
return;
}
@ -332,23 +341,44 @@ public void RegisterOnSignOutCallback(Action<FirebaseUser> callback)
}
/// <summary>
/// function to retrieve the user's username from the database
/// helper function to run RetrieveUsername with no callback
/// </summary>
private void RetrieveUsername()
{
RetrieveUsernameWithCallback((result, username) => { });
}
/// <summary>
/// function to retrieve the user's username from the database
/// </summary>
private void RetrieveUsernameWithCallback(Action<DatabaseTransactionResult, string> callback)
{
if (!Status.Equals(FirebaseConnectionStatus.Connected)) return;
_db.Child("users").Child(_user.UserId).Child("username").GetValueAsync().ContinueWith(task =>
if (_user == null)
{
Debug.LogError("receiving username post-authentication but user is null (should be unreachable)");
callback(DatabaseTransactionResult.Unauthenticated, "Unknown");
return;
}
_db.Child("users").Child(_user.UserId).Child("username").GetValueAsync().ContinueWithOnMainThread(task =>
{
DatabaseTransactionResult result;
if (task.IsCompletedSuccessfully)
{
result = DatabaseTransactionResult.Ok;
_username = task.Result.Value.ToString();
Debug.Log($"our username is {_username}");
}
else
{
_username = "???";
result = DatabaseTransactionResult.Error;
_username = "Unknown";
Debug.LogError("failed to get username");
}
callback(result, _username);
});
}
@ -375,12 +405,46 @@ public void SignOutUser()
}
/// <summary>
/// abstraction function to submit a play to the database
/// abstraction function for the user to reset their password
/// </summary>
/// <param name="playData">play data</param>
/// <param name="email">the forgetful user's email lol</param>
/// <param name="callback">callback function to be invoked after the password reset email is sent</param>
public void ForgotPassword(string email, Action<bool> callback)
{
_auth.SendPasswordResetEmailAsync(email).ContinueWithOnMainThread(resetTask =>
{
if (resetTask.IsCompletedSuccessfully)
{
callback(true);
}
else
{
Debug.LogError(resetTask.Exception);
callback(false);
}
});
}
/// <summary>
/// abstraction function to get the user's recent scores from the database
/// </summary>
/// <param name="callback">
/// callback function that takes in a DatabaseTransactionResult and List of LocalPlayerData.Score
/// argument
/// </param>
public void GetRecentScores(Action<DatabaseTransactionResult, List<LocalPlayerData.Score>> callback)
{
// TODO
callback(DatabaseTransactionResult.Error, new List<LocalPlayerData.Score>(0));
}
/// <summary>
/// abstraction function to submit a score to the database
/// </summary>
/// <param name="score">score</param>
/// <param name="callback">callback function that takes in one DatabaseTransactionResult argument</param>
public void SubmitPlay(
PlayData playData,
public void SubmitScore(
LocalPlayerData.Score score,
Action<DatabaseTransactionResult> callback)
{
throw new NotImplementedException();
@ -421,20 +485,79 @@ public void SignOutUser()
throw new NotImplementedException();
}
// private void Playground()
// {
// // update username
// _db.Child("users").Child(_user.UserId).Child("username").SetValueAsync("newusername").ContinueWith(task => { });
//
// // update email
// _user.SendEmailVerificationBeforeUpdatingEmailAsync("name@example.com").ContinueWith(task => { });
//
// // update password
// _user.UpdatePasswordAsync("password").ContinueWith(task => { });
//
// // reset password
// _auth.SendPasswordResetEmailAsync("name@example.com").ContinueWith(task => { });
// }
/// <summary>
/// abstraction function to update the user's account details in the database
/// </summary>
/// <param name="target">the target account detail to update</param>
/// <param name="newValue">the new value for the target account detail</param>
/// <param name="callback">callback function that takes in one DatabaseTransactionResult argument</param>
/// <exception cref="ArgumentOutOfRangeException">thrown when the target is not a valid UserAccountDetailTargetEnum</exception>
public void UpdateUserAccountDetail(
UserAccountDetailTargetEnum target,
string newValue,
Action<DatabaseTransactionResult> callback)
{
if (!Status.Equals(FirebaseConnectionStatus.Connected)) callback(DatabaseTransactionResult.Unauthenticated);
if (_user == null)
{
callback(DatabaseTransactionResult.Unauthenticated);
return;
}
switch (target)
{
case UserAccountDetailTargetEnum.Email:
_user.SendEmailVerificationBeforeUpdatingEmailAsync(newValue).ContinueWithOnMainThread(task =>
{
if (task.IsCompletedSuccessfully)
{
callback(DatabaseTransactionResult.Ok);
}
else
{
Debug.LogError(task.Exception);
callback(DatabaseTransactionResult.Error);
}
});
break;
case UserAccountDetailTargetEnum.Username:
_db.Child("users").Child(_user.UserId).Child("username").SetValueAsync(newValue)
.ContinueWithOnMainThread(task =>
{
if (task.IsCompletedSuccessfully)
{
_username = newValue;
callback(DatabaseTransactionResult.Ok);
}
else
{
Debug.LogError(task.Exception);
callback(DatabaseTransactionResult.Error);
}
});
break;
case UserAccountDetailTargetEnum.Password:
_user.UpdatePasswordAsync(newValue).ContinueWithOnMainThread(task =>
{
if (task.IsCompletedSuccessfully)
{
callback(DatabaseTransactionResult.Ok);
}
else
{
Debug.LogError(task.Exception);
callback(DatabaseTransactionResult.Error);
}
});
break;
default:
throw new ArgumentOutOfRangeException(nameof(target), target, null);
}
}
/// <summary>
/// struct for a leaderboard entry
@ -452,16 +575,4 @@ public LeaderboardEntry(string username, float rating, int playCount)
PlayCount = playCount;
}
}
/// <summary>
/// struct for play data
/// </summary>
public struct PlayData
{
public int RoundsPlayed;
public float AverageOverallAccuracy;
public float AverageLightnessAccuracy;
public float AverageChromaAccuracy;
public float AverageHueAccuracy;
}
}

View file

@ -1,122 +0,0 @@
/*
* author: mark joshwel
* date: 29/5/2024
* description: common menu script for hover and click sound effects on ui toolkit buttons
*/
using System;
using UnityEngine;
using UnityEngine.UIElements;
/// <summary>
/// <para>
/// common menu class for hover and click sound effects on ui toolkit buttons
/// </para>
/// <para>
/// override <c>OnEnable()</c> with the first call to <c>base.OnEnable()</c>
/// or <c>PostEnable()</c>, and set the variable
/// <c>GameManager.DisplayState.associatedState</c> to the respective menu state
/// </para>
/// </summary>
public class CommonMenu : MonoBehaviour
{
/// <summary>
/// associated display state with the menu for the game manager to filter out menus in a scene
/// </summary>
public GameManager.DisplayState associatedState = GameManager.DisplayState.UnassociatedState;
/// <summary>
/// audio clip for menu button click
/// </summary>
[SerializeField] public AudioClip menuButtonClick;
/// <summary>
/// audio clip for menu button hover
/// </summary>
[SerializeField] public AudioClip menuButtonHover;
/// <summary>
/// manager for the game state
/// </summary>
protected GameManager Game;
/// <summary>
/// the visual element object for the menu
/// </summary>
protected VisualElement UI;
/// <summary>
/// checks if The Menu (2022) was set up correctly
/// </summary>
/// <exception cref="Exception">throws an exception if UI, Game and Audio are not set</exception>
private void Start()
{
if (associatedState == GameManager.DisplayState.UnassociatedState)
throw new Exception("associatedState not set");
if (Game == null)
throw new Exception("Game not set (was base.OnEnable() or PostEnable() called?)");
}
/// <summary>
/// override this class but call <c>base.OnEnable()</c> first.
/// also set the <c>associatedState</c> variable to the respective menu state
/// </summary>
public virtual void OnEnable()
{
PostEnable();
}
/// <summary>
/// function to subscribe to mouse events and assign managers
/// </summary>
public void PostEnable()
{
// get audio manager singleton instance from the world
UI = GetComponent<UIDocument>().rootVisualElement;
Game = GameManager.Instance;
// subscribe to hover events
UI.RegisterCallback<PointerOverEvent>(HoverListener);
}
/// <summary>
/// function listener for <c>PointerOverEvents</c> and plays a hover sound if it's a button
/// </summary>
/// <param name="evt">event from UIE callback</param>
public virtual void HoverListener(PointerOverEvent evt)
{
// check for button
if (evt.target is Button)
// play hover sound
PlayHover();
}
/// <summary>
/// function listener for <c>ClickEvents</c> and plays a click sound if it's a button
/// </summary>
/// <param name="evt">event from UIE callback</param>
public virtual void ClickListener(ClickEvent evt)
{
// check for button
if (evt.target is Button)
// play click sound
PlayClick();
}
/// <summary>
/// generic decoupled function to play click sound
/// </summary>
public virtual void PlayClick()
{
AudioManager.Instance.PlayClipOnChannel(menuButtonClick, AudioManager.AudioChannel.SoundEffects);
}
/// <summary>
/// generic decoupled function to play hover sound
/// </summary>
public virtual void PlayHover()
{
AudioManager.Instance.PlayClipOnChannel(menuButtonHover, AudioManager.AudioChannel.SoundEffects);
}
}

View file

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 26a2a7f124fc93c47aae6ca6569f68b9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,4 +1,5 @@
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UIElements;
/// <summary>
@ -11,12 +12,10 @@ public class GameManager : MonoBehaviour
/// </summary>
public enum DisplayState
{
ScreenStart,
OverlayAccountManagement,
OverlaySettings,
OverlayLeaderboard,
Game,
UnassociatedState
Nothing,
PlayView,
LeaderboardView,
AccountView,
}
/// <summary>
@ -24,6 +23,11 @@ public enum DisplayState
/// </summary>
public static GameManager Instance;
/// <summary>
/// the current display state of the game
/// </summary>
[SerializeField] private DisplayState state = DisplayState.Nothing;
/// <summary>
/// the visual element object for game ui (hud/prompts/tooltips)
/// </summary>
@ -34,6 +38,11 @@ public enum DisplayState
/// </summary>
public Backend Backend;
/// <summary>
/// the local player data object for storing player data
/// </summary>
private LocalPlayerData _localPlayerData;
/// <summary>
/// enforces singleton behaviour; sets doesn't destroy on load and checks for multiple instances
/// </summary>
@ -54,6 +63,21 @@ private void Awake()
}
}
private void Start()
{
SetDisplayState(DisplayState.PlayView);
// load the local player data and refresh the ui
_localPlayerData = new LocalPlayerData();
_localPlayerData.LoadFromTheWorld();
// register a callback to refresh the ui when the player signs in
Backend.RegisterOnSignInCallback(_ =>
{
_localPlayerData.LoadFromTheWorld();
});
}
/// <summary>
/// called when the game object is enabled
/// </summary>
@ -70,4 +94,15 @@ private void OnDestroy()
{
Backend.Cleanup();
}
/// <summary>
/// function to show a menu based on the enum passed,
/// and any other necessary actions
/// </summary>
/// <param name="newDisplayState">the game menu to show</param>
public void SetDisplayState(DisplayState newDisplayState)
{
var currentDisplayState = St;
switch (newDisplayState)
}
}

View file

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
public class LocalPlayerData
{
/// <summary>
/// last known email used
/// </summary>
public string LastKnownEmail = "";
/// <summary>
/// last known username used
/// </summary>
public string LastKnownUsername = "Guest";
/// <summary>
/// queue of the 10 most recent local scores
/// </summary>
public Queue<Score> RecentLocalScores = new(10);
/// <summary>
/// queue of the 10 most recent online scores,
/// used in user rating calculation and accuracy display stats
/// </summary>
public Queue<Score> RecentOnlineScores = new(10);
/// <summary>
/// queue of the best online scores,
/// used in user rating calculation and accuracy display stats
/// </summary>
public Queue<Score> BestOnlineScores = new(30);
/// <summary>
/// loads player data from player prefs and database
/// </summary>
public void LoadFromTheWorld()
{
// 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);
// if any of the values are invalid, don't add the score
if (noOfRounds < 0 || l < 0 || c < 0 || h < 0) continue;
RegisterLocalScore(new Score(timestamp, noOfRounds, l, c, h));
}
// load online scores
RecentOnlineScores.Clear();
GameManager.Instance.Backend.GetRecentScores((dtr, recentOnlineScores) =>
{
foreach (var onlineScore in recentOnlineScores)
{
if (RecentOnlineScores.Count > 10) RecentOnlineScores.Dequeue();
RecentOnlineScores.Enqueue(onlineScore);
}
});
}
/// <summary>
/// registers a score to the player's local data
/// </summary>
/// <param name="score">the score to register</param>
public void RegisterLocalScore(Score score)
{
if (RecentLocalScores.Count > 10) RecentLocalScores.Dequeue();
RecentLocalScores.Enqueue(score);
}
/// <summary>
/// saves player data to player prefs
/// </summary>
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);
idx++;
}
// online scores are already saved in the backend
}
public struct Score
{
/// <summary>
/// timestamp of the score
/// </summary>
public DateTime Timestamp;
/// <summary>
/// number of rounds played (0-100)
/// </summary>
public int NoOfRounds;
/// <summary>
/// average lightness accuracy across all rounds (0-100)
/// </summary>
public float AvgLightnessAccuracy;
/// <summary>
/// average chroma accuracy across all rounds (0-100)
/// </summary>
public float AvgChromaAccuracy;
/// <summary>
/// average hue accuracy across all rounds (0-100)
/// </summary>
public float AvgHueAccuracy;
/// <summary>
/// constructor for the score struct
/// </summary>
/// <param name="timestamp">timestamp of the score</param>
/// <param name="noOfRounds">number of rounds played (0-100)</param>
/// <param name="l">average lightness accuracy across all rounds (0-100)</param>
/// <param name="c">average chroma accuracy across all rounds (0-100)</param>
/// <param name="h">average hue accuracy across all rounds (0-100)</param>
public Score(DateTime timestamp = new(), int noOfRounds = 1, float l = 100.0f, float c = 100.0f,
float h = 100.0f)
{
Timestamp = timestamp;
NoOfRounds = noOfRounds;
AvgLightnessAccuracy = l;
AvgChromaAccuracy = c;
AvgHueAccuracy = h;
}
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 61389b17ec0645a6a0a9fe10bdaef72a
timeCreated: 1731833904

View file

@ -0,0 +1,18 @@
using UnityEngine;
public class Playground
{
private float _score;
private void SomethingSomethingScore()
{
var score = 0.0f;
_score = score;
}
private void ShowTheScore()
{
Debug.Log(_score);
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 662d126b54a54d9ebe6a1dc716d199e3
timeCreated: 1731830473

View file

@ -0,0 +1,146 @@
using UnityEngine;
using UnityEngine.UIElements;
/// <summary>
/// singleton for a single source of truth game state and flow management
/// </summary>
public class SideViewUI : MonoBehaviour
{
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Button _accountButton;
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Label _chromaAccuracyText;
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Label _hueAccuracyText;
/// <summary>
/// leaderboard button for showing the leaderboard
/// </summary>
private Button _leaderboardButton;
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Label _lightnessAccuracyText;
/// <summary>
/// play button for starting the game
/// </summary>
private Button _playButton;
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Label _playerNameText;
/// <summary>
/// settings button for showing the settings menu
/// </summary>
private Label _playerRatingText;
/// <summary>
/// function to subscribe button events to their respective functions
/// </summary>
private void OnEnable()
{
var ui = GetComponent<UIDocument>().rootVisualElement;
_playButton = ui.Q<Button>("PlayButton");
_playButton.clicked += OnPlayButtonClicked;
_leaderboardButton = ui.Q<Button>("LeaderboardButton");
_leaderboardButton.clicked += OnLeaderboardButtonClicked;
_accountButton = ui.Q<Button>("AccountButton");
_accountButton.clicked += OnAccountButtonClicked;
_lightnessAccuracyText = ui.Q<Label>("LightnessAccuracyText");
_hueAccuracyText = ui.Q<Label>("HueAccuracyText");
_chromaAccuracyText = ui.Q<Label>("ChromaAccuracyText");
}
/// <summary>
/// function to update the ui with the latest data
/// </summary>
/// <param name="localPlayerData"></param>
private void RenderFromPlayerData(LocalPlayerData localPlayerData)
{
// calculate averages from both recent local scores and online scores
var totalLightessAcc = 0f;
var totalChromaAcc = 0f;
var totalHueAcc = 0f;
var totalRounds = 0;
// average out all the scores we have to get a stable-ish average
foreach (var localScore in localPlayerData.RecentLocalScores)
{
totalLightessAcc += localScore.AvgLightnessAccuracy;
totalChromaAcc += localScore.AvgChromaAccuracy;
totalHueAcc += localScore.AvgHueAccuracy;
totalRounds += localScore.NoOfRounds;
}
foreach (var onlineScore in localPlayerData.RecentOnlineScores)
{
totalLightessAcc += onlineScore.AvgLightnessAccuracy;
totalChromaAcc += onlineScore.AvgChromaAccuracy;
totalHueAcc += onlineScore.AvgHueAccuracy;
totalRounds += onlineScore.NoOfRounds;
}
foreach (var onlineScore in localPlayerData.BestOnlineScores)
{
totalLightessAcc += onlineScore.AvgLightnessAccuracy;
totalChromaAcc += onlineScore.AvgChromaAccuracy;
totalHueAcc += onlineScore.AvgHueAccuracy;
totalRounds += onlineScore.NoOfRounds;
}
// finally, set the labels
_playerNameText.text = GameManager.Instance.Backend.GetUsername();
_lightnessAccuracyText.text = $"{(totalLightessAcc / totalRounds):F}";
_hueAccuracyText.text = $"{(totalHueAcc / totalRounds):F}";
_chromaAccuracyText.text = $"{(totalChromaAcc / totalRounds):F}";
// and set the player rating, but after we get it from the backend
// (god I LOVE async (I am LYING out of my teeth))
GameManager.Instance.Backend.CalculateUserRating((dtr, rating) =>
{
if (dtr != Backend.DatabaseTransactionResult.Ok) return;
_playerRatingText.text = $"{rating:F}";
});
}
/// <summary>
/// function to show the play view
/// </summary>
private static void OnPlayButtonClicked()
{
GameManager.Instance.SetDisplayState(GameManager.DisplayState.PlayView);
}
/// <summary>
/// function to show the leaderboard view
/// </summary>
private static void OnLeaderboardButtonClicked()
{
GameManager.Instance.SetDisplayState(GameManager.DisplayState.LeaderboardView);
}
/// <summary>
/// function to show the account view
/// </summary>
private static void OnAccountButtonClicked()
{
GameManager.Instance.SetDisplayState(GameManager.DisplayState.AccountView);
}
}

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 17a7c29fa9f16244682562d58604fdba
guid: 6351b7620d84e2d43bc4f59c5f3f8b5c
MonoImporter:
externalObjects: {}
serializedVersion: 2

View file

@ -100,6 +100,7 @@ #AccountFields TextField #unity-text-input {
border-top-color: rgba(0, 0, 0, 0);
border-bottom-color: rgba(0, 0, 0, 0);
background-color: rgb(255, 232, 249);
color: rgb(15, 13, 27);
}
#AccountFields Button {