Commit d4ba32ba authored by Yousef Sameh's avatar Yousef Sameh

New QuestionBank Api and schema

parent ada9ac1c
...@@ -38,33 +38,38 @@ public class AppRouter : MonoBehaviour ...@@ -38,33 +38,38 @@ public class AppRouter : MonoBehaviour
private async UniTask Boot() private async UniTask Boot()
{ {
// 1. Init Supabase // 1. Try cache first — instant
// var cached = UserService.Instance.LoadFromCache();
// 2. Init Supabase
bool ready = await SupabaseManager.Instance.Initialize(); bool ready = await SupabaseManager.Instance.Initialize();
if (!ready) if (!ready)
{ {
ShowError("Failed to connect to server"); // Offline but have cache — go to home
// if (cached != null) { GoToHome(); return; }
ShowError("Failed to connect");
return; return;
} }
// 2. Listen for unexpected sign-outs
SupabaseManager.Instance.AddAuthStateListener(OnAuthStateChanged);
// 3. Ensure session // 3. Ensure session
var authResult = await SupabaseAuthentication.Instance.EnsureSession(); var authResult = await SupabaseAuthentication.Instance.EnsureSession();
if (authResult.IsT1) if (authResult.IsT1)
{ {
// if (cached != null) { GoToHome(); return; }
GoToLogin(); GoToLogin();
return; return;
} }
// 4. Try load profile // 4. Refresh from network
var profileResult = await UserService.Instance.GetCurrentUser(); var profileResult = await UserService.Instance.GetCurrentUser();
profileResult.Switch( profileResult.Switch(
success => GoToHome(), success => GoToHome(),
error => GoToLogin() error =>
{
// if (cached != null) GoToHome(); // stale cache, still usable
GoToLogin();
}
); );
_booted = true;
} }
// ─── Auth State Listener (Safety Net Only) ─────────────────────── // ─── Auth State Listener (Safety Net Only) ───────────────────────
...@@ -101,6 +106,7 @@ public class AppRouter : MonoBehaviour ...@@ -101,6 +106,7 @@ public class AppRouter : MonoBehaviour
{ {
if (SceneManager.GetActiveScene().name != "MainMenu") if (SceneManager.GetActiveScene().name != "MainMenu")
await SceneManager.LoadSceneAsync("MainMenu"); await SceneManager.LoadSceneAsync("MainMenu");
UserService.Instance.StartListening();
HideSplash(); HideSplash();
} }
......
using System; using System;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using OneOf; using OneOf;
using Supabase.Realtime;
using Supabase.Realtime.PostgresChanges;
using UnityEngine; using UnityEngine;
public class UserService public class UserService
...@@ -8,11 +13,98 @@ public class UserService ...@@ -8,11 +13,98 @@ public class UserService
private static UserService _instance; private static UserService _instance;
public static UserService Instance => _instance ??= new UserService(); public static UserService Instance => _instance ??= new UserService();
private const string CACHE_KEY = "cached_user_profile";
public User CurrentUser { get; private set; } public User CurrentUser { get; private set; }
public bool HasProfile => CurrentUser != null; public bool HasProfile => CurrentUser != null;
public bool IsFromCache { get; private set; }
public event Action<User> OnUserChanged; public event Action<User> OnUserChanged;
public event Action<string, string> OnRankChanged; // oldRank, newRank
public event Action<int, int> OnPointsChanged; // oldPoints, newPoints
private RealtimeChannel _channel;
// ─── Realtime ────────────────────────────────────────────────────
public async Task StartListening()
{
var client = SupabaseManager.Instance.Supabase();
var authUser = client?.Auth.CurrentUser;
if (authUser == null) return;
if (_channel != null) return;
private UserService() { } await client.Realtime.ConnectAsync();
Debug.Log("Current UserID" + authUser.Id);
_channel = client.Realtime.Channel("user_profile");
_channel.Register(new PostgresChangesOptions("public", "users", PostgresChangesOptions.ListenType.All, filter: $"id=eq.{authUser.Id}"));
_channel.AddPostgresChangeHandler(
PostgresChangesOptions.ListenType.All,
(sender, change) =>
{
// Run on main thread to avoid Unity threading issues
UniTask.Post(() =>
{
var newUser = change.Model<User>();
CurrentUser = newUser;
IsFromCache = false;
OnUserChanged?.Invoke(CurrentUser);
SaveToCache(CurrentUser);
});
}
);
await _channel.Subscribe();
}
public void StopListening()
{
if (_channel == null) return;
_channel.Unsubscribe();
_channel = null;
}
// ─── Cache ───────────────────────────────────────────────────────
private void SaveToCache(User user)
{
if (user == null)
{
PlayerPrefs.DeleteKey(CACHE_KEY);
PlayerPrefs.Save();
return;
}
var json = JsonConvert.SerializeObject(user);
PlayerPrefs.SetString(CACHE_KEY, json);
PlayerPrefs.Save();
}
public User LoadFromCache()
{
var json = PlayerPrefs.GetString(CACHE_KEY, null);
if (string.IsNullOrEmpty(json)) return null;
try
{
var user = JsonConvert.DeserializeObject<User>(json);
CurrentUser = user;
IsFromCache = true;
OnUserChanged?.Invoke(CurrentUser);
return user;
}
catch
{
PlayerPrefs.DeleteKey(CACHE_KEY);
return null;
}
}
// ─── Network ─────────────────────────────────────────────────────
public async UniTask<OneOf<UserResult, ErrorResult>> GetCurrentUser() public async UniTask<OneOf<UserResult, ErrorResult>> GetCurrentUser()
{ {
...@@ -32,7 +124,17 @@ public class UserService ...@@ -32,7 +124,17 @@ public class UserService
if (response?.Models == null || response.Models.Count == 0) if (response?.Models == null || response.Models.Count == 0)
return new ErrorResult("Profile not found"); return new ErrorResult("Profile not found");
var oldUser = CurrentUser;
CurrentUser = response.Models[0]; CurrentUser = response.Models[0];
IsFromCache = false;
SaveToCache(CurrentUser);
if (oldUser != null && oldUser.Rank != CurrentUser.Rank)
OnRankChanged?.Invoke(oldUser.Rank, CurrentUser.Rank);
if (oldUser != null && oldUser.Points != CurrentUser.Points)
OnPointsChanged?.Invoke(oldUser.Points, CurrentUser.Points);
OnUserChanged?.Invoke(CurrentUser); OnUserChanged?.Invoke(CurrentUser);
return new UserResult(CurrentUser); return new UserResult(CurrentUser);
} }
...@@ -45,10 +147,10 @@ public class UserService ...@@ -45,10 +147,10 @@ public class UserService
public async UniTask<OneOf<UserResult, ErrorResult>> CreateProfile( public async UniTask<OneOf<UserResult, ErrorResult>> CreateProfile(
string username, string username,
string school = null,
string grade = null, string grade = null,
string sex = null, string sex = null,
string age = null) string curriculum = null,
string term = null)
{ {
try try
{ {
...@@ -59,21 +161,16 @@ public class UserService ...@@ -59,21 +161,16 @@ public class UserService
var user = new User var user = new User
{ {
Username = username, Username = username,
School = school,
Grade = grade, Grade = grade,
Sex = sex, Sex = sex,
Age = age, Curriculum = curriculum,
Term = term,
Rank = "normal", Rank = "normal",
Points = 0, Points = 0
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
}; };
await client.From<User>().Insert(user); await client.From<User>().Insert(user);
return await GetCurrentUser();
// Re-fetch to get server-set fields (id, timestamps)
var fetchResult = await GetCurrentUser();
return fetchResult;
} }
catch (Exception ex) catch (Exception ex)
{ {
...@@ -84,10 +181,10 @@ public class UserService ...@@ -84,10 +181,10 @@ public class UserService
public async UniTask<OneOf<UserResult, ErrorResult>> UpdateProfile( public async UniTask<OneOf<UserResult, ErrorResult>> UpdateProfile(
string username = null, string username = null,
string school = null,
string grade = null, string grade = null,
string sex = null, string sex = null,
string age = null) string curriculum = null,
string term = null)
{ {
try try
{ {
...@@ -98,17 +195,16 @@ public class UserService ...@@ -98,17 +195,16 @@ public class UserService
var update = new User(); var update = new User();
if (username != null) update.Username = username; if (username != null) update.Username = username;
if (school != null) update.School = school;
if (grade != null) update.Grade = grade; if (grade != null) update.Grade = grade;
if (sex != null) update.Sex = sex; if (sex != null) update.Sex = sex;
if (age != null) update.Age = age; if (curriculum != null) update.Curriculum = curriculum;
if (term != null) update.Term = term;
await client await client
.From<User>() .From<User>()
.Where(x => x.Id == authUser.Id) .Where(x => x.Id == authUser.Id)
.Update(update); .Update(update);
// Re-fetch
return await GetCurrentUser(); return await GetCurrentUser();
} }
catch (Exception ex) catch (Exception ex)
...@@ -118,9 +214,15 @@ public class UserService ...@@ -118,9 +214,15 @@ public class UserService
} }
} }
// ─── Clear ───────────────────────────────────────────────────────
public void ClearUser() public void ClearUser()
{ {
StopListening();
CurrentUser = null; CurrentUser = null;
IsFromCache = false;
PlayerPrefs.DeleteKey(CACHE_KEY);
PlayerPrefs.Save();
OnUserChanged?.Invoke(null); OnUserChanged?.Invoke(null);
} }
} }
\ No newline at end of file
...@@ -2,50 +2,36 @@ using System; ...@@ -2,50 +2,36 @@ using System;
using Supabase.Postgrest.Attributes; using Supabase.Postgrest.Attributes;
using Supabase.Postgrest.Models; using Supabase.Postgrest.Models;
using Newtonsoft.Json;
[Table("users")] [Table("users")]
public class User : BaseModel public class User : BaseModel
{ {
[PrimaryKey("id")] [PrimaryKey("id")]
[JsonProperty("id")]
public string Id { get; set; } public string Id { get; set; }
[Column("user_name")] [Column("user_name")]
[JsonProperty("user_name")]
public string Username { get; set; } public string Username { get; set; }
[Column("rank")] [Column("rank")]
[JsonProperty("rank")]
public string Rank { get; set; } public string Rank { get; set; }
[Column("points")] [Column("points")]
[JsonProperty("points")]
public int Points { get; set; } = 0; public int Points { get; set; } = 0;
[Column("created_at")] [Column("created_at")]
[JsonProperty("created_at")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
[Column("updated_at")] [Column("updated_at")]
[JsonProperty("updated_at")]
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
[Column("school")]
[JsonProperty("school")]
public string School { get; set; }
[Column("grade")] [Column("grade")]
[JsonProperty("grade")]
public string Grade { get; set; } public string Grade { get; set; }
[Column("sex")] [Column("sex")]
[JsonProperty("sex")]
public string Sex { get; set; } public string Sex { get; set; }
[Column("age")] [Column("curriculum")]
[JsonProperty("age")] public string Curriculum { get; set; }
public string Age { get; set; }
}
[Column("term")]
public string Term { get; set; }
}
\ No newline at end of file
...@@ -47,6 +47,8 @@ public class HomeController : MonoBehaviour ...@@ -47,6 +47,8 @@ public class HomeController : MonoBehaviour
private void OnUserChange(User user) private void OnUserChange(User user)
{ {
print($"[HomeController] Updating user info: {user?.Username}, Rank: {user?.Rank}, Points: {user?.Points}");
username.text = user.Username; username.text = user.Username;
xp.text = user.Points.ToString(); xp.text = user.Points.ToString();
rank.text = AppUtils.RankToArabic(user.Rank); rank.text = AppUtils.RankToArabic(user.Rank);
......
...@@ -14,6 +14,7 @@ public class LeaderboardController : MonoBehaviour ...@@ -14,6 +14,7 @@ public class LeaderboardController : MonoBehaviour
var root = leaderboardDocument.rootVisualElement; var root = leaderboardDocument.rootVisualElement;
leaderboardScrollView = root.Q<ScrollView>("Leaderboard"); leaderboardScrollView = root.Q<ScrollView>("Leaderboard");
UserService.Instance.OnUserChanged += _ => LoadLeaderboard();
LoadLeaderboard().Forget(); LoadLeaderboard().Forget();
} }
......
...@@ -7,9 +7,9 @@ public class LoginController : MonoBehaviour ...@@ -7,9 +7,9 @@ public class LoginController : MonoBehaviour
private TextField username; private TextField username;
private DropdownField grade; private DropdownField grade;
private DropdownField school;
private DropdownField sex; private DropdownField sex;
private TextField age; private DropdownField term;
private DropdownField curriculum;
private Button register; private Button register;
...@@ -18,12 +18,10 @@ public class LoginController : MonoBehaviour ...@@ -18,12 +18,10 @@ public class LoginController : MonoBehaviour
var root = uIDocument.rootVisualElement; var root = uIDocument.rootVisualElement;
username = root.Q<TextField>("Username"); username = root.Q<TextField>("Username");
username.languageDirection = LanguageDirection.RTL;
grade = root.Q<DropdownField>("Grade"); grade = root.Q<DropdownField>("Grade");
school = root.Q<DropdownField>("School");
sex = root.Q<DropdownField>("Sex"); sex = root.Q<DropdownField>("Sex");
age = root.Q<TextField>("Age"); term = root.Q<DropdownField>("Term");
curriculum = root.Q<DropdownField>("Curriculum");
register = root.Q<Button>("Register"); register = root.Q<Button>("Register");
register.clicked += RegisterAnon; register.clicked += RegisterAnon;
...@@ -44,7 +42,7 @@ public class LoginController : MonoBehaviour ...@@ -44,7 +42,7 @@ public class LoginController : MonoBehaviour
return; return;
} }
var signUp = await UserService.Instance.CreateProfile(username.text, grade.value, school.value, sex.value, age.text); var signUp = await UserService.Instance.CreateProfile(username.text, curriculum.value, grade.value, sex.value, term.value);
signUp.Switch(user => signUp.Switch(user =>
{ {
AppRouter.GoToHome(); AppRouter.GoToHome();
......
...@@ -23,4 +23,9 @@ public class ProfileController : MonoBehaviour ...@@ -23,4 +23,9 @@ public class ProfileController : MonoBehaviour
{ {
name.text = user.Username; name.text = user.Username;
} }
private void OnDestroy()
{
UserService.Instance.OnUserChanged -= OnUserChange;
}
} }
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<ui:Label text="👤" name="TextFieldLabel" language-direction="RTL" class="emoji" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 40px; -unity-text-align: middle-right; margin-left: 5px;"/> <ui:Label text="👤" name="TextFieldLabel" language-direction="RTL" class="emoji" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 40px; -unity-text-align: middle-right; margin-left: 5px;"/>
<ui:Label text="اسم المستخدم" name="TextFieldLabel" language-direction="RTL" class="base-text-light" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;);"/> <ui:Label text="اسم المستخدم" name="TextFieldLabel" language-direction="RTL" class="base-text-light" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;);"/>
</ui:VisualElement> </ui:VisualElement>
<ui:TextField label="" placeholder-text="" name="Username" value="" language-direction="RTL" class="textField" style="flex-direction: row-reverse; color: rgb(0, 0, 0); -unity-font-definition: url(&quot;project://database/Assets/UI%20Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss?fileID=2230732570650464555&amp;guid=901fb73b2529c134f9cf372789759383&amp;type=3#NotInter-Regular&quot;); -unity-text-align: upper-right; -unity-text-generator: advanced; justify-content: space-between; align-content: flex-start;"> <ui:TextField label="" placeholder-text="" name="Username" value="" language-direction="RTL" class="textField" style="flex-direction: row-reverse; color: rgb(0, 0, 0); -unity-font-definition: url(&quot;project://database/Assets/UI%20Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss?fileID=2230732570650464555&amp;guid=901fb73b2529c134f9cf372789759383&amp;type=3#NotInter-Regular&quot;); -unity-text-align: upper-right; -unity-text-generator: advanced; justify-content: space-between; align-content: flex-start; white-space: nowrap; text-overflow: clip;">
<ui:Image source="project://database/Assets/Art/export/mail@3x.png?fileID=2800000&amp;guid=0d76662a81af3a7408ca3c2975f08b8f&amp;type=3#mail@3x" tint-color="rgb(158, 158, 158)" style="margin-left: 25px; width: 51px; display: none;"/> <ui:Image source="project://database/Assets/Art/export/mail@3x.png?fileID=2800000&amp;guid=0d76662a81af3a7408ca3c2975f08b8f&amp;type=3#mail@3x" tint-color="rgb(158, 158, 158)" style="margin-left: 25px; width: 51px; display: none;"/>
</ui:TextField> </ui:TextField>
</ui:VisualElement> </ui:VisualElement>
...@@ -34,8 +34,8 @@ ...@@ -34,8 +34,8 @@
<ui:Label text="النظام التعليمي" name="TextFieldLabel" language-direction="RTL" class="base-text-light" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;);"/> <ui:Label text="النظام التعليمي" name="TextFieldLabel" language-direction="RTL" class="base-text-light" style="color: rgb(117, 117, 117); margin-bottom: 20px; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;);"/>
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement name="Menu" class="padding" style="flex-grow: 0; background-color: rgb(255, 255, 255); height: auto; flex-shrink: 10; border-top-width: 0; border-right-width: 0; border-bottom-width: 0; border-left-width: 0; border-top-left-radius: 50px; border-top-right-radius: 50px; border-bottom-right-radius: 50px; border-bottom-left-radius: 50px; padding-top: 25px; padding-right: 25px; padding-bottom: 25px; padding-left: 25px; justify-content: space-between; border-left-color: rgba(0, 0, 0, 0.25); border-right-color: rgba(0, 0, 0, 0.25); border-top-color: rgba(0, 0, 0, 0.25); border-bottom-color: rgba(0, 0, 0, 0.25); display: flex; margin-bottom: 0;"> <ui:VisualElement name="Menu" class="padding" style="flex-grow: 0; background-color: rgb(255, 255, 255); height: auto; flex-shrink: 10; border-top-width: 0; border-right-width: 0; border-bottom-width: 0; border-left-width: 0; border-top-left-radius: 50px; border-top-right-radius: 50px; border-bottom-right-radius: 50px; border-bottom-left-radius: 50px; padding-top: 25px; padding-right: 25px; padding-bottom: 25px; padding-left: 25px; justify-content: space-between; border-left-color: rgba(0, 0, 0, 0.25); border-right-color: rgba(0, 0, 0, 0.25); border-top-color: rgba(0, 0, 0, 0.25); border-bottom-color: rgba(0, 0, 0, 0.25); display: flex; margin-bottom: 0;">
<ui:Button text="" name="Grade" class="row-btn" style="height: 80px; -unity-text-align: middle-right;"> <ui:Button text="" name="" class="row-btn" style="height: 80px; -unity-text-align: middle-right;">
<ui:DropdownField label="" choices="اختر المدرسة...,مصري عربي,مصري لغات" index="0" language-direction="RTL" name="School" style="margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0; position: absolute; flex-grow: 0; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;); -unity-text-generator: advanced; color: rgb(66, 66, 66); -unity-text-align: upper-right; flex-direction: row;"/> <ui:DropdownField label="" choices="اختر المدرسة...,مصري عربي,مصري لغات" index="0" language-direction="RTL" name="Curriculum" style="margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0; position: absolute; flex-grow: 0; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;); -unity-text-generator: advanced; color: rgb(66, 66, 66); -unity-text-align: upper-right; flex-direction: row;"/>
</ui:Button> </ui:Button>
</ui:VisualElement> </ui:VisualElement>
</ui:VisualElement> </ui:VisualElement>
...@@ -59,7 +59,7 @@ ...@@ -59,7 +59,7 @@
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement name="Menu" class="padding" style="flex-grow: 0; background-color: rgb(255, 255, 255); height: auto; flex-shrink: 10; border-top-width: 0; border-right-width: 0; border-bottom-width: 0; border-left-width: 0; border-top-left-radius: 50px; border-top-right-radius: 50px; border-bottom-right-radius: 50px; border-bottom-left-radius: 50px; padding-top: 25px; padding-right: 25px; padding-bottom: 25px; padding-left: 25px; justify-content: space-between; border-left-color: rgba(0, 0, 0, 0.25); border-right-color: rgba(0, 0, 0, 0.25); border-top-color: rgba(0, 0, 0, 0.25); border-bottom-color: rgba(0, 0, 0, 0.25); display: flex; margin-bottom: 0;"> <ui:VisualElement name="Menu" class="padding" style="flex-grow: 0; background-color: rgb(255, 255, 255); height: auto; flex-shrink: 10; border-top-width: 0; border-right-width: 0; border-bottom-width: 0; border-left-width: 0; border-top-left-radius: 50px; border-top-right-radius: 50px; border-bottom-right-radius: 50px; border-bottom-left-radius: 50px; padding-top: 25px; padding-right: 25px; padding-bottom: 25px; padding-left: 25px; justify-content: space-between; border-left-color: rgba(0, 0, 0, 0.25); border-right-color: rgba(0, 0, 0, 0.25); border-top-color: rgba(0, 0, 0, 0.25); border-bottom-color: rgba(0, 0, 0, 0.25); display: flex; margin-bottom: 0;">
<ui:Button text="" name="Level" class="row-btn" style="height: 80px; -unity-text-align: middle-right;"> <ui:Button text="" name="Level" class="row-btn" style="height: 80px; -unity-text-align: middle-right;">
<ui:DropdownField label="" choices="اختر فصل دراسي...,الفصل الدراسي الاول,الفصل الدراسي الثاني" index="0" language-direction="RTL" name="Grade" style="margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0; position: absolute; flex-grow: 0; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;); -unity-text-generator: advanced; color: rgb(66, 66, 66); -unity-text-align: upper-right; flex-direction: row;"/> <ui:DropdownField label="" choices="اختر فصل دراسي...,الفصل الدراسي الاول,الفصل الدراسي الثاني" index="0" language-direction="RTL" name="Term" style="margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0; position: absolute; flex-grow: 0; font-size: 35px; -unity-font-definition: url(&quot;project://database/Assets/ALArcade/Hakwaty%20Font/TSHakwaty-DemiBold.otf?fileID=12800000&amp;guid=566b773a07b3d064aa1f4c6ef7b6f6fa&amp;type=3#TSHakwaty-DemiBold&quot;); -unity-text-generator: advanced; color: rgb(66, 66, 66); -unity-text-align: upper-right; flex-direction: row;"/>
</ui:Button> </ui:Button>
</ui:VisualElement> </ui:VisualElement>
</ui:VisualElement> </ui:VisualElement>
......
...@@ -1116,10 +1116,10 @@ RectTransform: ...@@ -1116,10 +1116,10 @@ RectTransform:
m_Children: [] m_Children: []
m_Father: {fileID: 5665338920870028329} m_Father: {fileID: 5665338920870028329}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 22.572838, y: 0} m_AnchoredPosition: {x: 22.572838, y: -25.96685}
m_SizeDelta: {x: 51.9337, y: 0} m_SizeDelta: {x: 0, y: 51.9337}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1367268893384981932 --- !u!222 &1367268893384981932
CanvasRenderer: CanvasRenderer:
...@@ -3222,10 +3222,10 @@ RectTransform: ...@@ -3222,10 +3222,10 @@ RectTransform:
m_Children: [] m_Children: []
m_Father: {fileID: 5665338920870028329} m_Father: {fileID: 5665338920870028329}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 83.71851, y: 0} m_AnchoredPosition: {x: 83.71851, y: -25.96685}
m_SizeDelta: {x: 51.9337, y: 0} m_SizeDelta: {x: 0, y: 51.9337}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6044436298841298018 --- !u!222 &6044436298841298018
CanvasRenderer: CanvasRenderer:
...@@ -3406,10 +3406,10 @@ RectTransform: ...@@ -3406,10 +3406,10 @@ RectTransform:
m_Children: [] m_Children: []
m_Father: {fileID: 5665338920870028329} m_Father: {fileID: 5665338920870028329}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 267.15555, y: 0} m_AnchoredPosition: {x: 267.15555, y: -25.96685}
m_SizeDelta: {x: 51.9337, y: 0} m_SizeDelta: {x: 0, y: 51.9337}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7681831561278972478 --- !u!222 &7681831561278972478
CanvasRenderer: CanvasRenderer:
...@@ -3793,10 +3793,10 @@ RectTransform: ...@@ -3793,10 +3793,10 @@ RectTransform:
m_Children: [] m_Children: []
m_Father: {fileID: 5665338920870028329} m_Father: {fileID: 5665338920870028329}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 206.00986, y: 0} m_AnchoredPosition: {x: 206.00986, y: -25.96685}
m_SizeDelta: {x: 51.9337, y: 0} m_SizeDelta: {x: 0, y: 51.9337}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8749602556167845090 --- !u!222 &8749602556167845090
CanvasRenderer: CanvasRenderer:
...@@ -4052,10 +4052,10 @@ RectTransform: ...@@ -4052,10 +4052,10 @@ RectTransform:
m_Children: [] m_Children: []
m_Father: {fileID: 5665338920870028329} m_Father: {fileID: 5665338920870028329}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 144.8642, y: 0} m_AnchoredPosition: {x: 144.8642, y: -25.96685}
m_SizeDelta: {x: 51.9337, y: 0} m_SizeDelta: {x: 0, y: 51.9337}
m_Pivot: {x: 0.5, y: 0.5} m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8187631058268110602 --- !u!222 &8187631058268110602
CanvasRenderer: CanvasRenderer:
......
...@@ -2374,8 +2374,8 @@ MonoBehaviour: ...@@ -2374,8 +2374,8 @@ MonoBehaviour:
m_OnCullStateChanged: m_OnCullStateChanged:
m_PersistentCalls: m_PersistentCalls:
m_Calls: [] m_Calls: []
text: "\u0646\u062C\u062D\u062A \u0627\u0644\u0645\u0647\u0645\u0629" text: "\u0646\u0642\u0627\u0637\u0643"
fontStack: {fileID: 11400000, guid: 0029e5efb4c7a12f1ac9136de794e6dc, type: 2} fontStack: {fileID: 11400000, guid: 657d8db1dabff4325ae70686887b629b, type: 2}
appearance: {fileID: 11400000, guid: 3a559cf5d653f05ea807e1be5655df92, type: 2} appearance: {fileID: 11400000, guid: 3a559cf5d653f05ea807e1be5655df92, type: 2}
fontSize: 72 fontSize: 72
baseDirection: 2 baseDirection: 2
......
...@@ -296,108 +296,7 @@ namespace com.al_arcade.cs ...@@ -296,108 +296,7 @@ namespace com.al_arcade.cs
{ {
return new CsQuestion[] return new CsQuestion[]
{ {
new()
{
id = "1", source = "علوم - الوحدة 3",
wrong_word = "القمر", correct_answer = "الشمس",
words = new CsWord[]
{
new() { word_text = "القمر", position = 0, is_wrong = true },
new() { word_text = "مصدر", position = 1, is_wrong = false },
new() { word_text = "الضوء", position = 2, is_wrong = false },
new() { word_text = "والحرارة", position = 3, is_wrong = false },
new() { word_text = "للأرض", position = 4, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "الشمس", is_correct = true },
new() { option_text = "النجوم", is_correct = false },
new() { option_text = "الكواكب", is_correct = false },
new() { option_text = "المذنبات", is_correct = false },
}
},
new()
{
id = "2", source = "كيمياء",
wrong_word = "الأكسجين", correct_answer = "النيتروجين",
words = new CsWord[]
{
new() { word_text = "يتكون", position = 0, is_wrong = false },
new() { word_text = "معظم", position = 1, is_wrong = false },
new() { word_text = "الغلاف", position = 2, is_wrong = false },
new() { word_text = "الجوي", position = 3, is_wrong = false },
new() { word_text = "من", position = 4, is_wrong = false },
new() { word_text = "الأكسجين", position = 5, is_wrong = true },
},
options = new CsOption[]
{
new() { option_text = "النيتروجين", is_correct = true },
new() { option_text = "ثاني أكسيد الكربون", is_correct = false },
new() { option_text = "الهيليوم", is_correct = false },
}
},
new()
{
id = "3", source = "أحياء",
wrong_word = "الجذر", correct_answer = "الورقة",
words = new CsWord[]
{
new() { word_text = "الجذر", position = 0, is_wrong = true },
new() { word_text = "هي", position = 1, is_wrong = false },
new() { word_text = "الجزء", position = 2, is_wrong = false },
new() { word_text = "المسؤول", position = 3, is_wrong = false },
new() { word_text = "عن", position = 4, is_wrong = false },
new() { word_text = "البناء", position = 5, is_wrong = false },
new() { word_text = "الضوئي", position = 6, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "الورقة", is_correct = true },
new() { option_text = "الساق", is_correct = false },
new() { option_text = "الزهرة", is_correct = false },
new() { option_text = "البذرة", is_correct = false },
}
},
new()
{
id = "4", source = "فيزياء",
wrong_word = "تنخفض", correct_answer = "ترتفع",
words = new CsWord[]
{
new() { word_text = "تنخفض", position = 0, is_wrong = true },
new() { word_text = "درجة", position = 1, is_wrong = false },
new() { word_text = "غليان", position = 2, is_wrong = false },
new() { word_text = "الماء", position = 3, is_wrong = false },
new() { word_text = "عند", position = 4, is_wrong = false },
new() { word_text = "مئة", position = 5, is_wrong = false },
new() { word_text = "سلسيوس", position = 6, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "ترتفع", is_correct = true },
new() { option_text = "تثبت", is_correct = false },
new() { option_text = "تتذبذب", is_correct = false },
}
},
new()
{
id = "5", source = "جغرافيا",
wrong_word = "خمس", correct_answer = "سبع",
words = new CsWord[]
{
new() { word_text = "يتكون", position = 0, is_wrong = false },
new() { word_text = "العالم", position = 1, is_wrong = false },
new() { word_text = "من", position = 2, is_wrong = false },
new() { word_text = "خمس", position = 3, is_wrong = true },
new() { word_text = "قارات", position = 4, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "ست", is_correct = false },
new() { option_text = "سبع", is_correct = true },
new() { option_text = "ثمان", is_correct = false },
}
},
}; };
} }
......
...@@ -83,9 +83,15 @@ namespace com.al_arcade.cs ...@@ -83,9 +83,15 @@ namespace com.al_arcade.cs
if (uiManager != null && uiManager.isMusicOn) if (uiManager != null && uiManager.isMusicOn)
SSAudioManager.EnsureInstance().PlayMusic(); SSAudioManager.EnsureInstance().PlayMusic();
var filter = new QuestionFilter()
.CurriculumId(0)
.SubjectId(0)
.GradeId(session.gradeId)
.Count(session.questionCount)
.Shuffle(true);
yield return api.FetchCs( yield return api.FetchCs(
session.buildType, session.classCode, filter,
session.questionCount, session.gradeId,
qs => _questions = qs, qs => _questions = qs,
err => onError(err) err => onError(err)
); );
...@@ -445,7 +451,7 @@ namespace com.al_arcade.cs ...@@ -445,7 +451,7 @@ namespace com.al_arcade.cs
_targetGroup.RemoveMember(_targetGroup.Targets[1].Object); _targetGroup.RemoveMember(_targetGroup.Targets[1].Object);
} }
_groupFraming.CenterOffset = new Vector2(0f, -0.7f); _groupFraming.CenterOffset = new Vector2(0f, -0.8f);
} }
private IEnumerator UnlockAfterCooldown() private IEnumerator UnlockAfterCooldown()
......
...@@ -308,44 +308,7 @@ namespace com.al_arcade.cs ...@@ -308,44 +308,7 @@ namespace com.al_arcade.cs
{ {
return new CsQuestion[] return new CsQuestion[]
{ {
new()
{
id = "1", source = "علوم",
wrong_word = "القمر", correct_answer = "الشمس",
words = new CsWord[]
{
new() { word_text = "القمر", position = 0, is_wrong = true },
new() { word_text = "مصدر", position = 1, is_wrong = false },
new() { word_text = "الضوء", position = 2, is_wrong = false },
new() { word_text = "والحرارة", position = 3, is_wrong = false },
new() { word_text = "للأرض", position = 4, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "الشمس", is_correct = true },
new() { option_text = "النجوم", is_correct = false },
new() { option_text = "الكواكب", is_correct = false },
}
},
new()
{
id = "2", source = "جغرافيا",
wrong_word = "خمس", correct_answer = "سبع",
words = new CsWord[]
{
new() { word_text = "يتكون", position = 0, is_wrong = false },
new() { word_text = "العالم", position = 1, is_wrong = false },
new() { word_text = "من", position = 2, is_wrong = false },
new() { word_text = "خمس", position = 3, is_wrong = true },
new() { word_text = "قارات", position = 4, is_wrong = false },
},
options = new CsOption[]
{
new() { option_text = "ست", is_correct = false },
new() { option_text = "سبع", is_correct = true },
new() { option_text = "ثمان", is_correct = false },
}
},
}; };
} }
} }
......
...@@ -207,7 +207,7 @@ public class CsSentence : MonoBehaviour ...@@ -207,7 +207,7 @@ public class CsSentence : MonoBehaviour
{ {
csWord = rect.gameObject.AddComponent<CsWordButton>(); csWord = rect.gameObject.AddComponent<CsWordButton>();
} }
csWord.Setup(_question.words[i].word_text, _question.words[i].is_wrong, i, elementWidth, 1f); csWord.Setup(_question.words[i].word_text, _question.words[i].is_distractor, i, elementWidth, 1f);
// Animate to the locally offset position // Animate to the locally offset position
rect.DOLocalMove(targetPos, 0.3f).SetEase(Ease.OutCubic).OnComplete(() => rect.DOLocalMove(targetPos, 0.3f).SetEase(Ease.OutCubic).OnComplete(() =>
...@@ -224,32 +224,6 @@ public class CsSentence : MonoBehaviour ...@@ -224,32 +224,6 @@ public class CsSentence : MonoBehaviour
} }
} }
_targetGroup.RemoveMember(_targetGroup.Targets[1].Object);
// Update Camera Group (top + left + right)
// Top
_targetGroup.AddMember(_wordTexts[0].transform.parent, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
var widestRect = widestWord.transform.parent.GetComponent<RectTransform>();
var offsetFromCenter = Vector3.right * (widestRect.sizeDelta.x / 2);
var right = new GameObject("Right");
var rightRect = right.AddComponent<RectTransform>();
rightRect.SetParent(widestRect);
rightRect.localPosition = offsetFromCenter;
rightRect.SetParent(widestRect.parent);
var left = new GameObject("left");
var leftRect = left.AddComponent<RectTransform>();
leftRect.SetParent(widestRect);
leftRect.localPosition = -offsetFromCenter;
leftRect.SetParent(widestRect.parent);
_targetGroup.AddMember(rightRect, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
_targetGroup.AddMember(leftRect, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
if (_background != null) Destroy(_background); if (_background != null) Destroy(_background);
} }
...@@ -365,8 +339,6 @@ public class CsSentence : MonoBehaviour ...@@ -365,8 +339,6 @@ public class CsSentence : MonoBehaviour
cnvRt.offsetMax = Vector2.zero; cnvRt.offsetMax = Vector2.zero;
cnvRt.offsetMin = Vector2.zero; cnvRt.offsetMin = Vector2.zero;
_targetGroup.AddMember(cnvRt, 1f, 2f);
canvasObj.transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity); canvasObj.transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity);
var _backgroundImage = backGO.AddComponent<Image>(); var _backgroundImage = backGO.AddComponent<Image>();
_backgroundImage.type = Image.Type.Sliced; _backgroundImage.type = Image.Type.Sliced;
...@@ -378,6 +350,31 @@ public class CsSentence : MonoBehaviour ...@@ -378,6 +350,31 @@ public class CsSentence : MonoBehaviour
cnvRt.DOLocalMoveY(0f, 0.3f).SetEase(Ease.OutCubic); cnvRt.DOLocalMoveY(0f, 0.3f).SetEase(Ease.OutCubic);
_backgroundImage.DOFade(1f, 0.3f).SetEase(Ease.OutCubic).From(0f); _backgroundImage.DOFade(1f, 0.3f).SetEase(Ease.OutCubic).From(0f);
StartCoroutine(AnimateTextIn()); StartCoroutine(AnimateTextIn());
// Update Camera Group (top + left + right)
// Top
_targetGroup.AddMember(_wordTexts[0].transform.parent, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
var widestRect = widestWord.transform.parent.GetComponent<RectTransform>();
var offsetFromCenter = Vector3.right * (widestRect.sizeDelta.x / 2);
var right = new GameObject("Right");
var rightRect = right.AddComponent<RectTransform>();
rightRect.SetParent(widestRect);
rightRect.localPosition = offsetFromCenter;
rightRect.SetParent(widestRect.parent);
var left = new GameObject("left");
var leftRect = left.AddComponent<RectTransform>();
leftRect.SetParent(widestRect);
leftRect.localPosition = -offsetFromCenter;
leftRect.SetParent(widestRect.parent);
_targetGroup.AddMember(rightRect, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
_targetGroup.AddMember(leftRect, 1f, _wordTexts[0].transform.parent.GetComponent<RectTransform>().rect.height * 0.02f);
} }
} }
...@@ -274,21 +274,7 @@ namespace com.al_arcade.mcq ...@@ -274,21 +274,7 @@ namespace com.al_arcade.mcq
{ {
return new McqQuestion[] return new McqQuestion[]
{ {
new() { id="1", question_text="ما هي عاصمة مصر؟",
answer1="القاهرة", answer2="الإسكندرية",
answer3="أسوان", answer4="المنصورة", source="جغرافيا" },
new() { id="2", question_text="كم عدد كواكب المجموعة الشمسية؟",
answer1="8", answer2="9",
answer3="7", answer4="10", source="علوم" },
new() { id="3", question_text="ما هو أكبر محيط في العالم؟",
answer1="المحيط الهادي", answer2="المحيط الأطلسي",
answer3="المحيط الهندي", answer4="المحيط المتجمد", source="جغرافيا" },
new() { id="4", question_text="ما هو الغاز الأكثر وفرة في الغلاف الجوي؟",
answer1="النيتروجين", answer2="الأكسجين",
answer3="ثاني أكسيد الكربون", answer4="الهيليوم", source="كيمياء" },
new() { id="5", question_text="من اخترع المصباح الكهربائي؟",
answer1="توماس إديسون", answer2="نيكولا تسلا",
answer3="ألبرت آينشتاين", answer4="إسحاق نيوتن", source="تاريخ العلوم" },
}; };
} }
......
...@@ -73,9 +73,15 @@ namespace com.al_arcade.mcq ...@@ -73,9 +73,15 @@ namespace com.al_arcade.mcq
var session = SSGameSession.EnsureInstance(); var session = SSGameSession.EnsureInstance();
var api = SSApiManager.EnsureInstance(); var api = SSApiManager.EnsureInstance();
var filter = new QuestionFilter()
.CurriculumId(0)
.SubjectId(0)
.GradeId(session.gradeId)
.Count(session.questionCount)
.Shuffle(true);
yield return api.FetchMcq( yield return api.FetchMcq(
session.buildType, session.classCode, filter,
session.questionCount, session.gradeId,
qs => _questions = qs, qs => _questions = qs,
err => onError(err) err => onError(err)
); );
...@@ -153,7 +159,7 @@ namespace com.al_arcade.mcq ...@@ -153,7 +159,7 @@ namespace com.al_arcade.mcq
BeginGameplay(); BeginGameplay();
} }
public void ResetGame() public override void ResetGame()
{ {
ResetBaseState(); ResetBaseState();
_state = McqGameState.Idle; _state = McqGameState.Idle;
...@@ -311,8 +317,9 @@ namespace com.al_arcade.mcq ...@@ -311,8 +317,9 @@ namespace com.al_arcade.mcq
// ─── Gate Spawning ─────────────────────────────────────────────────── // ─── Gate Spawning ───────────────────────────────────────────────────
private void SpawnGates(McqQuestion question) private void SpawnGates(McqQuestion question)
{ {
string[] answers = question.GetShuffledAnswers(out int correctIdx); var shuffled = McqHelper.Shuffle(question);
_correctGateIndex = correctIdx; string[] answers = shuffled.answers;
_correctGateIndex = shuffled.correctIndex;
_activeGates.Clear(); _activeGates.Clear();
if (questionDisplay != null) if (questionDisplay != null)
...@@ -327,7 +334,7 @@ namespace com.al_arcade.mcq ...@@ -327,7 +334,7 @@ namespace com.al_arcade.mcq
for (int i = 0; i < answers.Length; i++) for (int i = 0; i < answers.Length; i++)
{ {
Vector3 gatePos = basePos + Vector3.right * (startX + i * gateSpacing); Vector3 gatePos = basePos + Vector3.right * (startX + i * gateSpacing);
var gate = CreateGate(gatePos, i, answers[i], i == correctIdx); var gate = CreateGate(gatePos, i, answers[i], i == _correctGateIndex);
_activeGates.Add(gate); _activeGates.Add(gate);
gate.transform.DOScale(Vector3.one, 0.5f) gate.transform.DOScale(Vector3.one, 0.5f)
......
...@@ -328,15 +328,7 @@ namespace com.al_arcade.mcq ...@@ -328,15 +328,7 @@ namespace com.al_arcade.mcq
{ {
return new McqQuestion[] return new McqQuestion[]
{ {
new() { id="1", question_text="ما هي عاصمة مصر؟",
answer1="القاهرة", answer2="الإسكندرية",
answer3="أسوان", answer4="المنصورة", source="جغرافيا" },
new() { id="2", question_text="كم عدد كواكب المجموعة الشمسية؟",
answer1="8", answer2="9",
answer3="7", answer4="10", source="علوم" },
new() { id="3", question_text="ما هو أكبر محيط في العالم؟",
answer1="المحيط الهادي", answer2="المحيط الأطلسي",
answer3="المحيط الهندي", answer4="المحيط المتجمد", source="جغرافيا" },
}; };
} }
} }
......
fileFormatVersion: 2
guid: 20ba488acbcf34d438c13bfb24840820
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System;
using LightSide;
using DG.Tweening;
public class AnswerButtonUI : MonoBehaviour
{
[SerializeField] private Button button;
[SerializeField] private Image answerImageComponent;
[SerializeField] private UniText answerTextComponent;
[SerializeField] private LayoutGroup answerLayout;
[SerializeField] private Image buttonBackgroundImage;
[SerializeField] private Color normalColor = Color.white;
[SerializeField] private Color normalTextColor = Color.black;
[SerializeField] private Color selectedCorrectColor = Color.green;
[SerializeField] private Color selectedIncorrectColor = Color.red;
[SerializeField] private Color correctAnswerColor = new Color(0.2f, 0.8f, 0.2f, 1f);
[SerializeField] private Color disabledColor = new Color(0.7f, 0.7f, 0.7f, 1f);
private int answerIndex;
private Action<int> onClicked;
private bool isInteractable = true;
private CanvasGroup canvasGroup;
private RectTransform rectTransform;
private void OnEnable()
{
if (button != null)
{
button.onClick.RemoveAllListeners();
}
}
private void OnDisable()
{
DOTween.Kill(this);
}
/// <summary>
/// Setup answer button with text and optional image
/// </summary>
public void Setup(string answerText, Sprite answerImage, int index, Action<int> callback)
{
if (button != null)
button.Select();
answerIndex = index;
onClicked = callback;
isInteractable = true;
// Cache components
if (rectTransform == null)
rectTransform = GetComponent<RectTransform>();
if (canvasGroup == null)
canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = gameObject.AddComponent<CanvasGroup>();
// Setup text
if (answerTextComponent != null)
{
answerTextComponent.Text = answerText;
answerTextComponent.color = normalTextColor;
}
// Setup image
if (answerImageComponent != null)
{
if (answerImage != null)
{
answerImageComponent.sprite = answerImage;
answerImageComponent.gameObject.SetActive(true);
}
else
{
answerImageComponent.gameObject.SetActive(false);
}
}
// Setup button
if (button != null)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => HandleClick());
button.interactable = true;
}
// Reset background color
if (buttonBackgroundImage != null)
{
buttonBackgroundImage.color = normalColor;
}
// Rebuild layout
if (answerLayout != null)
{
LayoutRebuilder.ForceRebuildLayoutImmediate(answerLayout.transform as RectTransform);
}
}
/// <summary>
/// Handle button click
/// </summary>
private void HandleClick()
{
if (!isInteractable || button == null || !button.interactable)
return;
onClicked?.Invoke(answerIndex);
}
/// <summary>
/// Mark this answer as selected and correct
/// </summary>
public void SetSelected(bool isCorrect)
{
isInteractable = false;
if (buttonBackgroundImage != null)
{
DOTween.Kill(buttonBackgroundImage, true);
buttonBackgroundImage.DOColor(
isCorrect ? selectedCorrectColor : selectedIncorrectColor,
0.3f
).SetEase(Ease.OutQuad);
}
if (button != null)
{
button.interactable = false;
}
}
/// <summary>
/// Show this as the correct answer (when user selected wrong)
/// </summary>
public void SetCorrect()
{
isInteractable = false;
if (buttonBackgroundImage != null)
{
DOTween.Kill(buttonBackgroundImage, true);
buttonBackgroundImage.DOColor(correctAnswerColor, 0.3f)
.SetEase(Ease.OutQuad);
}
if (button != null)
{
button.interactable = false;
}
}
/// <summary>
/// Disable this button without highlighting
/// </summary>
public void SetDisabled()
{
isInteractable = false;
if (buttonBackgroundImage != null)
{
DOTween.Kill(buttonBackgroundImage, true);
buttonBackgroundImage.DOColor(disabledColor, 0.3f)
.SetEase(Ease.OutQuad);
}
if (button != null)
{
button.interactable = false;
}
}
/// <summary>
/// Reset button to normal state
/// </summary>
public void Reset()
{
isInteractable = true;
if (buttonBackgroundImage != null)
{
DOTween.Kill(buttonBackgroundImage, true);
buttonBackgroundImage.color = normalColor;
}
if (answerTextComponent != null)
{
answerTextComponent.color = normalTextColor;
}
if (canvasGroup != null)
{
canvasGroup.alpha = 1f;
}
if (rectTransform != null)
{
rectTransform.localScale = Vector3.one;
}
if (button != null)
{
button.interactable = true;
}
}
/// <summary>
/// Get answer index
/// </summary>
public int GetAnswerIndex()
{
return answerIndex;
}
/// <summary>
/// Check if button is interactable
/// </summary>
public bool IsInteractable()
{
return isInteractable;
}
}
\ No newline at end of file
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Events;
using DG.Tweening;
using com.al_arcade.shared;
using com.al_arcade.mcq;
using System;
namespace com.al_arcade.mcq
{
public class NewMCQGameManger : MonoBehaviour
{
[SerializeField] private int totalLives = 5;
[SerializeField] private float questionTime = 5;
[SerializeField] private float streakBonusThreshold = 3;
[Header("Audio — SFX Clips")]
[SerializeField] private AudioClip sfxCorrect;
[SerializeField] private AudioClip sfxWrong;
[SerializeField] private AudioClip sfxClick;
[SerializeField] private AudioClip sfxVictory;
[SerializeField] private AudioClip sfxDefeat;
[SerializeField] private AudioClip sfxWhoosh;
[SerializeField] private AudioClip sfxPop;
[SerializeField] private AudioClip sfxCheer;
[SerializeField] private AudioClip sfxCountdown;
private DateTime gameStartTime;
private McqQuestion[] _questions;
private McqQuestion currentQuestion;
private int currentQuestionIndex;
private int _score, _streak, _bestStreak, _lives;
private int _correctCount, _wrongCount;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
SetupAudioManager();
_lives = totalLives;
if (NewMCQUIManager.Instance != null)
{
NewMCQUIManager.Instance.SetLives(totalLives, totalLives);
NewMCQUIManager.Instance.SetScore(0);
NewMCQUIManager.Instance.ShowGameUI();
}
StartCoroutine(StartGame());
}
private IEnumerator StartGame()
{
NewMCQUIManager.Instance.ShowLoading("جاري تحميل الأسئلة...");
var session = SSGameSession.EnsureInstance();
var api = SSApiManager.EnsureInstance();
string error = null;
yield return api.FetchMcq(
session.buildType, session.classCode,
session.questionCount, session.gradeId,
qs => _questions = qs,
err => error = err
);
if (error != null || _questions == null || _questions.Length == 0)
{
NewMCQUIManager.Instance.ShowError(error ?? "لا توجد أسئلة متاحة");
yield break;
}
NewMCQUIManager.Instance.HideLoading();
gameStartTime = DateTime.Now;
ShowNextQuestion();
}
private void ShowNextQuestion()
{
NewMCQUIManager.Instance.SetProgress(currentQuestionIndex, _questions.Length);
if (currentQuestionIndex >= _questions.Length)
{
StartCoroutine(VictorySequence());
return;
}
McqQuestion question = _questions[currentQuestionIndex];
QuestionUi.Instance.DisplayQuestion(question, OnAnswerSubmitted);
}
private void OnAnswerSubmitted(int selectedIndex, bool isCorrect)
{
Debug.Log($"Answer Index: {selectedIndex}, Correct:{isCorrect}");
if (isCorrect)
{
_correctCount++;
_streak++;
if (_bestStreak < _streak)
{
_bestStreak = _streak;
}
int points = 100;
if (_streak >= streakBonusThreshold)
points += (_streak - (int)streakBonusThreshold + 1) * 25;
_score += points;
NewMCQUIManager.Instance.SetScore(_score);
ShowCorrectFeedback(points);
var audio = SSAudioManager.Instance;
if (audio != null)
{
if (audio.sfxCorrect != null) audio.PlayCorrect();
else audio.PlayCorrectBeep();
}
}
else
{
_streak = 0;
_wrongCount++;
_lives--;
ShowWrongFeedback();
NewMCQUIManager.Instance.SetLives(_lives, totalLives);
if (_lives <= 0)
{
StartCoroutine(GameOverSequence());
return;
}
var audio = SSAudioManager.Instance;
if (audio != null)
{
if (audio.sfxWrong != null) audio.PlayWrong();
else audio.PlayWrongBeep();
}
}
Invoke(nameof(GoToNextQuestion), 1f);
}
private void GoToNextQuestion()
{
currentQuestionIndex++;
QuestionUi.Instance.ResetForNextQuestion();
ShowNextQuestion();
}
private IEnumerator VictorySequence()
{
var audio = SSAudioManager.Instance;
if (audio != null)
{ if (audio.sfxVictory != null) audio.PlayVictory(); else audio.PlaySuccessJingle(); }
var particles = SSParticleManager.Instance;
if (particles != null) particles.PlayScreenConfetti();
yield return new WaitForSeconds(0.5f);
NewMCQUIManager.Instance.ShowResults(_score, _correctCount, _wrongCount,
_bestStreak, _questions.Length, true);
GameHistoryService.Instance.AddGame("mcq", _score, gameStartTime, DateTime.Now);
}
private IEnumerator GameOverSequence()
{
var audio = SSAudioManager.Instance;
if (audio != null)
{ if (audio.sfxDefeat != null) audio.PlayDefeat(); else audio.PlayFailBuzz(); }
yield return new WaitForSeconds(0.5f);
NewMCQUIManager.Instance.ShowResults(_score, _correctCount, _wrongCount,
_bestStreak, _questions.Length, false);
}
private void ShowCorrectFeedback(int points)
{
if (NewMCQUIManager.Instance != null)
{
string msg = _streak >= streakBonusThreshold
? $"ممتاز! +{points} (سلسلة {_streak}×)"
: $"صحيح! +{points}";
NewMCQUIManager.Instance.ShowFeedback(msg, true);
}
}
private void ShowWrongFeedback()
{
if (NewMCQUIManager.Instance != null) NewMCQUIManager.Instance.ShowFeedback("خطأ!", false);
if (Camera.main != null)
{
Debug.Log("shake");
DOTween.Kill(Camera.main.transform, "camShake");
Camera.main.transform.DOShakePosition(0.4f, 0.3f, 15, 90f, false, true)
.SetEase(Ease.OutQuad).SetId("camShake");
}
}
private void SetupAudioManager()
{
var a = SSAudioManager.EnsureInstance();
if (sfxCorrect != null) a.sfxCorrect = sfxCorrect;
if (sfxWrong != null) a.sfxWrong = sfxWrong;
if (sfxClick != null) a.sfxClick = sfxClick;
if (sfxVictory != null) a.sfxVictory = sfxVictory;
if (sfxDefeat != null) a.sfxDefeat = sfxDefeat;
if (sfxWhoosh != null) a.sfxWhoosh = sfxWhoosh;
if (sfxPop != null) a.sfxPop = sfxPop;
if (sfxCheer != null) a.sfxCheer = sfxCheer;
if (sfxCountdown != null) a.sfxCountdown = sfxCountdown;
}
}
}
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
// using ALArcade.ArabicTMP;
namespace com.al_arcade.mcq
{
using DG.Tweening;
using LightSide;
using shared;
using UnityEngine.SceneManagement;
public class NewMCQUIManager : MonoBehaviour
{
public static NewMCQUIManager Instance;
[Header("CanvasGroups")]
[SerializeField] private CanvasGroup _gameUI;
[SerializeField] private CanvasGroup _loadingUI;
[SerializeField] private CanvasGroup _errorUI;
[SerializeField] private CanvasGroup _resultsUI;
[SerializeField] private CanvasGroup _feedbackUI;
[Header("Game UI")]
[SerializeField] private UniText _scoreText;
[SerializeField] private UniText _streakText;
[SerializeField] private UniText _progressText;
[SerializeField] private UniText _loadingText;
[SerializeField] private UniText _errorText;
[SerializeField] private UniText _feedbackText;
[SerializeField] private Image _feedbackBg;
[SerializeField] private Image[] _heartIcons;
[Header("Results UI")]
[SerializeField] private UniText _resultTitle;
[SerializeField] private UniText _resultScore;
[SerializeField] private UniText _resultCorrect;
[SerializeField] private UniText _resultWrong;
[SerializeField] private UniText _resultStreak;
[SerializeField] private Button _resultRestartBtn;
[SerializeField] private Slider _progressSlider;
[SerializeField] private GameObject _winIcon;
[SerializeField] private GameObject _loseIcon;
private Image _progressFill;
[Header("Events")]
public UnityEvent onRestartClicked;
private void Awake()
{
if (_gameUI != null)
{
_gameUI.alpha = 0; _gameUI.gameObject.SetActive(false);
}
if (_loadingUI != null)
{
_loadingUI.alpha = 0; _loadingUI.gameObject.SetActive(false);
}
if (_errorUI != null)
{
_errorUI.alpha = 0; _errorUI.gameObject.SetActive(false);
}
if (_resultsUI != null)
{
_resultsUI.alpha = 0; _resultsUI.gameObject.SetActive(false);
}
if (_feedbackUI != null)
{
_feedbackUI.alpha = 0; _feedbackUI.gameObject.SetActive(true);
}
if (_winIcon != null) _winIcon.SetActive(false);
if (_loseIcon != null) _loseIcon.SetActive(false);
if(Instance != null)
{
Debug.LogError("there is two ui manager");
}
else
{
Instance = this;
}
}
private void Start()
{
_resultRestartBtn.onClick.AddListener(() => { SceneManager.LoadScene(SceneManager.GetActiveScene().name); });
}
public void ShowGameUI()
{
_gameUI.gameObject.SetActive(true);
_gameUI.DOFade(1f, 0.5f);
}
public void SetScore(int score)
{
if (_scoreText == null) return;
_scoreText.Text = score.ToString("N0");
DOTween.Kill(_scoreText.transform, "scorePunch");
_scoreText.transform.DOPunchScale(Vector3.one * 0.2f, 0.3f, 6, 0.3f)
.SetId("scorePunch");
}
public void SetStreak(int streak)
{
if (_streakText == null) return;
_streakText.Text = streak > 1 ? $"🔥 ×{streak}" : "";
if (streak > 1)
{
DOTween.Kill(_streakText.transform, "streakPop");
_streakText.transform.localScale = Vector3.one * 1.3f;
_streakText.transform.DOScale(Vector3.one, 0.4f)
.SetEase(Ease.OutElastic).SetId("streakPop");
}
}
public void SetLives(int lives, int maxLives)
{
for (int i = 0; i < _heartIcons.Length; i++)
{
bool active = i < maxLives;
_heartIcons[i].gameObject.SetActive(active);
if (active)
{
bool alive = i < lives;
_heartIcons[i].color = alive
? SSColorPalette.Danger
: SSColorPalette.WithAlpha(SSColorPalette.Danger, 0.2f);
if (!alive && i == lives)
{
DOTween.Kill(_heartIcons[i].transform);
_heartIcons[i].transform.DOPunchScale(Vector3.one * 0.5f, 0.3f);
}
}
}
}
public void SetProgress(int current, int total)
{
if (_progressText != null) _progressText.Text = $"{current} / {total}";
if (_progressFill != null && total > 0)
{
float t = (float)current / total;
DOTween.Kill(_progressFill.rectTransform, "progFill");
_progressFill.rectTransform.DOAnchorMax(new Vector2(t, 1f), 0.5f)
.SetEase(Ease.OutQuad).SetId("progFill");
}
if (_progressSlider != null)
{
DOTween.Kill("progSlider");
DOVirtual.Float(_progressSlider.value, (float)current, 0.5f, score =>
{
_progressSlider.value = score;
}).SetEase(Ease.OutQuad).SetId("progSlider");
}
}
public void ShowFeedback(string message, bool isCorrect)
{
if (_feedbackText != null) _feedbackText.Text = message;
if (_feedbackBg != null)
_feedbackBg.color = SSColorPalette.WithAlpha(
isCorrect ? SSColorPalette.Success : SSColorPalette.Danger, 0.92f);
DOTween.Kill(_feedbackUI);
_feedbackUI.alpha = 0;
var bgRect = _feedbackBg?.rectTransform;
if (bgRect != null)
{
print("Showing Feedback");
bgRect.localScale = new Vector3(0.5f, 0f, 1f);
var seq = DOTween.Sequence();
seq.Append(_feedbackUI.DOFade(1f, 0.15f));
seq.Join(bgRect.DOScaleX(1f, 0.25f).SetEase(Ease.OutBack));
seq.Join(bgRect.DOScaleY(1f, 0.2f).SetEase(Ease.OutBack).SetDelay(0.05f));
seq.AppendInterval(1.2f);
seq.Append(_feedbackUI.DOFade(0f, 0.3f));
}
}
public void ShowLoading(string msg)
{
_loadingUI.gameObject.SetActive(true);
if (_loadingText != null) _loadingText.Text = msg;
_loadingUI.DOFade(1f, 0.3f);
}
public void HideLoading()
{
_loadingUI.DOFade(0f, 0.3f)
.OnComplete(() => _loadingUI.gameObject.SetActive(false));
}
public void ShowError(string msg)
{
_errorUI.gameObject.SetActive(true);
if (_errorText != null) _errorText.Text = msg;
_errorUI.DOFade(1f, 0.3f);
}
public void ShowResults(int score, int correct, int wrong,
int bestStreak, int total, bool won)
{
_resultsUI.gameObject.SetActive(true);
_resultsUI.alpha = 0;
if (_resultTitle != null) _resultTitle.Text = won ? "أحسنت!" : "حظ أوفر!";
if (_resultScore != null) _resultScore.Text = score.ToString("N0");
if (_resultCorrect != null) _resultCorrect.Text = $"صحيح: {correct}";
if (_resultWrong != null) _resultWrong.Text = $"خطأ: {wrong}";
if (_resultStreak != null) _resultStreak.Text = $"أعلى سلسلة: {bestStreak}";
if (_winIcon != null) _winIcon.SetActive(won);
if (_loseIcon != null) _loseIcon.SetActive(!won);
var seq = DOTween.Sequence();
seq.Append(_resultsUI.DOFade(1f, 0.5f));
if (_resultTitle != null)
{
_resultTitle.transform.localScale = Vector3.zero;
seq.Append(_resultTitle.transform.DOScale(1f, 0.5f).SetEase(Ease.OutBack));
}
if (_winIcon != null)
{
_winIcon.transform.localScale = Vector3.zero;
seq.Append(_winIcon.transform.DOScale(1f, 0.5f).SetEase(Ease.OutBack));
}
if (_loseIcon != null)
{
_loseIcon.transform.localScale = Vector3.zero;
seq.Append(_loseIcon.transform.DOScale(1f, 0.5f).SetEase(Ease.OutBack));
}
if (_resultScore != null)
{
_resultScore.transform.localScale = Vector3.zero;
seq.Append(_resultScore.transform.DOScale(1f, 0.4f).SetEase(Ease.OutBack));
}
}
public void HideResults()
{
_resultsUI.DOFade(0f, 0.3f)
.OnComplete(() => _resultsUI.gameObject.SetActive(false));
}
public void RestartButtonFunction()
{
HideResults();
onRestartClicked?.Invoke();
McqGameManager.Instance.ResetGame();
McqGameManager.Instance.StartGame();
}
public void ResetUI()
{
_gameUI.gameObject.SetActive(false);
_loadingUI.gameObject.SetActive(false);
_errorUI.gameObject.SetActive(false);
_resultsUI.gameObject.SetActive(false);
_feedbackUI.alpha = 0;
}
}
}
fileFormatVersion: 2
guid: 168f088242b9231408ef684c9094c66e
\ No newline at end of file
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System;
using com.al_arcade.shared;
using LightSide;
using DG.Tweening;
public class QuestionUi : MonoBehaviour
{
public static QuestionUi Instance;
[SerializeField] private CanvasGroup questionPanelCanvasGroup;
[SerializeField] private LayoutGroup questionContainer;
[SerializeField] private Image questionImageComponent;
[SerializeField] private UniText questionTextComponent;
[SerializeField] private Transform answersGrid;
[SerializeField] private AnswerButtonUI[] answerButtons = new AnswerButtonUI[4];
[SerializeField] private LayoutGroup answersContainer;
[SerializeField] private CanvasGroup gameUICanvasGroup;
[Header("Animation Settings")]
[SerializeField] private float questionEntranceDuration = 0.6f;
[SerializeField] private float answerStaggerDelay = 0.1f;
[SerializeField] private float answerEntranceDuration = 0.4f;
[SerializeField] private float wrongAnswerShakeDuration = 0.4f;
[SerializeField] private float wrongAnswerShakeStrength = 15f;
private McqQuestion currentQuestion;
private int correctAnswerIndex = -1;
private bool hasAnswered = false;
private Action<int, bool> onAnswerSelected;
private Sequence questionSequence;
private Sequence[] answerSequences = new Sequence[4];
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Debug.LogError("there is two questionUI");
}
}
private void OnEnable()
{
if (answerButtons.Length != 4)
{
Debug.LogError("MultiChoiceQuestionUI: Must have exactly 4 answer buttons!");
}
// Auto-find CanvasGroup if not assigned
if (questionPanelCanvasGroup == null)
{
questionPanelCanvasGroup = GetComponent<CanvasGroup>();
}
if (gameUICanvasGroup == null && answersContainer != null)
{
gameUICanvasGroup = answersContainer.GetComponentInParent<CanvasGroup>();
}
}
/// <summary>
/// Display a multiple choice question with all its data + animations
/// </summary>
public void DisplayQuestion(McqQuestion question, Action<int, bool> onAnswerCallback = null)
{
if (question == null)
{
Debug.LogError("MultiChoiceQuestionUI: Question is null!");
return;
}
currentQuestion = question;
onAnswerSelected = onAnswerCallback;
hasAnswered = false;
correctAnswerIndex = -1;
// Kill previous animations
DOTween.Kill(questionSequence);
for (int i = 0; i < 4; i++)
{
DOTween.Kill(answerSequences[i]);
}
// Reset previous shuffle
question.ResetShuffle();
// Setup question display
SetupQuestion(question);
// Get shuffled answers WITH images
question.GetShuffledAnswersWithImages(
out string[] shuffledAnswers,
out Sprite[] shuffledImages,
out int correctIndex
);
correctAnswerIndex = correctIndex;
// Setup answer buttons with shuffled data
SetupAnswerButtons(shuffledAnswers, shuffledImages);
// Rebuild layout hierarchy
LayoutRebuilder.ForceRebuildLayoutImmediate(questionContainer.GetComponent<RectTransform>());
LayoutRebuilder.ForceRebuildLayoutImmediate(answersContainer.GetComponent<RectTransform>());
// Play entrance animations
// AnimateQuestionEntrance();
AnimateAnswersEntrance();
}
/// <summary>
/// Setup the question text and image
/// </summary>
private void SetupQuestion(McqQuestion question)
{
// Setup question text
questionTextComponent.Text = question.question_text;
// Setup question image (if exists)
if (question.questionImage != null)
{
questionImageComponent.sprite = question.questionImage;
questionImageComponent.gameObject.SetActive(true);
}
else
{
questionImageComponent.gameObject.SetActive(false);
}
}
/// <summary>
/// Setup all 4 answer buttons with text and images
/// </summary>
private void SetupAnswerButtons(string[] answers, Sprite[] images)
{
if (answers.Length != 4 || images.Length != 4)
{
Debug.LogError("MultiChoiceQuestionUI: Answer arrays must have exactly 4 elements!");
return;
}
for (int i = 0; i < 4; i++)
{
answerButtons[i].Setup(
answerText: answers[i],
answerImage: images[i],
index: i,
callback: OnAnswerClicked
);
}
}
/// Animate question entrance (fade only, no position change)
/// </summary>
private void AnimateQuestionEntrance()
{
if (questionPanelCanvasGroup == null)
return;
// Reset state
questionPanelCanvasGroup.alpha = 0;
questionSequence = DOTween.Sequence();
questionSequence.Append(questionPanelCanvasGroup.DOFade(1f, questionEntranceDuration));
}
/// <summary>
/// Animate answers entrance (staggered, spring pop)
/// </summary>
private void AnimateAnswersEntrance()
{
for (int i = 0; i < 4; i++)
{
AnimateAnswerButtonEntrance(i);
}
}
private void AnimateAnswerButtonEntrance(int index)
{
RectTransform answerRect = answerButtons[index].GetComponent<RectTransform>();
CanvasGroup answerCanvasGroup = answerButtons[index].GetComponent<CanvasGroup>();
if (answerCanvasGroup == null)
{
answerCanvasGroup = answerButtons[index].gameObject.AddComponent<CanvasGroup>();
}
// Reset state
answerCanvasGroup.alpha = 0;
answerRect.localScale = Vector3.zero;
float delay = index * answerStaggerDelay;
answerSequences[index] = DOTween.Sequence();
answerSequences[index].AppendInterval(delay);
answerSequences[index].Append(answerCanvasGroup.DOFade(1f, answerEntranceDuration * 0.5f));
answerSequences[index].Join(answerRect.DOScale(1f, answerEntranceDuration)
.SetEase(Ease.OutBack));
}
/// <summary>
/// Called when an answer button is clicked
/// </summary>
private void OnAnswerClicked(int selectedIndex)
{
Debug.Log("pressed");
if (hasAnswered)
return;
hasAnswered = true;
bool isCorrect = selectedIndex == correctAnswerIndex;
// Show visual feedback
ShowAnswerFeedback(selectedIndex, isCorrect);
// Invoke callback
onAnswerSelected?.Invoke(selectedIndex, isCorrect);
}
/// <summary>
/// Show visual feedback for selected answer
/// </summary>
private void ShowAnswerFeedback(int selectedIndex, bool isCorrect)
{
for (int i = 0; i < 4; i++)
{
if (i == selectedIndex)
{
answerButtons[i].SetSelected(isCorrect);
AnimateAnswerSelection(i, isCorrect);
}
else if (i == correctAnswerIndex && !isCorrect)
{
answerButtons[i].SetCorrect();
AnimateCorrectAnswer(i);
}
else
{
answerButtons[i].SetDisabled();
AnimateDisabledAnswer(i);
}
}
// Wrong answer screen shake
if (!isCorrect)
{
ShakeGameUI();
}
}
/// <summary>
/// Animate selected answer (pop + scale)
/// </summary>
private void AnimateAnswerSelection(int index, bool isCorrect)
{
RectTransform answerRect = answerButtons[index].GetComponent<RectTransform>();
DOTween.Sequence()
.Append(answerRect.DOScale(1.15f, 0.15f).SetEase(Ease.OutBack))
.Append(answerRect.DOScale(1f, 0.1f).SetEase(Ease.InQuad));
}
/// <summary>
/// Animate correct answer highlight (glow + pulse)
/// </summary>
private void AnimateCorrectAnswer(int index)
{
RectTransform answerRect = answerButtons[index].GetComponent<RectTransform>();
DOTween.Sequence()
.Append(answerRect.DOScale(1.05f, 0.2f).SetEase(Ease.OutBack))
.SetLoops(-1, LoopType.Yoyo)
.SetId($"correctAnswer_{index}");
}
/// <summary>
/// Animate disabled answer (fade out slightly)
/// </summary>
private void AnimateDisabledAnswer(int index)
{
CanvasGroup answerCanvasGroup = answerButtons[index].GetComponent<CanvasGroup>();
if (answerCanvasGroup == null)
return;
answerCanvasGroup.DOFade(0.5f, 0.3f).SetEase(Ease.InQuad);
}
/// <summary>
/// Screen shake for wrong answer (shake GameUI)
/// </summary>
private void ShakeGameUI()
{
if (gameUICanvasGroup == null)
return;
RectTransform gameUIRect = gameUICanvasGroup.GetComponent<RectTransform>();
gameUIRect.DOShakeAnchorPos(
wrongAnswerShakeDuration,
new Vector2(wrongAnswerShakeStrength, wrongAnswerShakeStrength * 0.5f),
vibrato: 12,
randomness: 90f,
snapping: false
).SetEase(Ease.OutQuad);
}
/// <summary>
/// Reset all buttons for next question
/// </summary>
public void ResetForNextQuestion()
{
hasAnswered = false;
correctAnswerIndex = -1;
// Kill pulsing animations
for (int i = 0; i < 4; i++)
{
DOTween.Kill($"correctAnswer_{i}");
answerButtons[i].Reset();
}
// Fade out question
//AnimateQuestionExit();
}
/// <summary>
/// Animate question exit (fade out only, reset position)
/// </summary>
private void AnimateQuestionExit()
{
if (questionPanelCanvasGroup == null)
return;
RectTransform questionRect = questionContainer.GetComponent<RectTransform>();
DOTween.Sequence()
.Append(questionPanelCanvasGroup.DOFade(0f, 0.3f))
.OnComplete(() =>
{
// Reset position back to original after fade
if (questionRect != null)
questionRect.anchoredPosition = Vector2.zero;
});
}
/// <summary>
/// Get current question
/// </summary>
public McqQuestion GetCurrentQuestion()
{
return currentQuestion;
}
/// <summary>
/// Get the correct answer index for current question
/// </summary>
public int GetCorrectAnswerIndex()
{
return correctAnswerIndex;
}
/// <summary>
/// Check if user has answered
/// </summary>
public bool HasAnswered()
{
return hasAnswered;
}
}
\ No newline at end of file
using System;
using Newtonsoft.Json;
namespace com.al_arcade.shared
{
/// <summary>
/// Data object for reporting a single question attempt to the server.
/// </summary>
[Serializable]
public class AttemptData
{
// Required
[JsonProperty("question_type")] public string questionType;
[JsonProperty("question_id")] public int questionId;
[JsonProperty("session_id")] public string sessionId;
[JsonProperty("is_correct")] public int isCorrect;
[JsonProperty("time_taken_ms")] public int timeTakenMs;
// Optional
[JsonProperty("is_skipped")] public int isSkipped;
[JsonProperty("is_timeout")] public int isTimeout;
[JsonProperty("selected_answer")] public string selectedAnswer;
[JsonProperty("correct_answer")] public string correctAnswer;
[JsonProperty("room_id")] public string roomId;
[JsonProperty("player_id")] public string playerId;
[JsonProperty("round_in_game")] public int? roundInGame;
[JsonProperty("total_rounds")] public int? totalRounds;
[JsonProperty("player_streak")] public int playerStreak;
[JsonProperty("player_score_at_time")] public int playerScoreAtTime;
[JsonProperty("players_in_room")] public int? playersInRoom;
[JsonProperty("answer_rank")] public int? answerRank;
[JsonProperty("hint_used")] public int hintUsed;
[JsonProperty("power_up_used")] public string powerUpUsed;
[JsonProperty("device_type")] public string deviceType = "unknown";
[JsonProperty("os")] public string os;
[JsonProperty("client_version")] public string clientVersion;
[JsonProperty("screen_width")] public int? screenWidth;
[JsonProperty("screen_height")] public int? screenHeight;
public AttemptData() { }
public AttemptData(string type, int qId, string session,
bool correct, int timeMs)
{
questionType = type;
questionId = qId;
sessionId = session;
isCorrect = correct ? 1 : 0;
timeTakenMs = timeMs;
}
}
[Serializable]
public class BatchAttemptPayload
{
public AttemptData[] attempts;
}
}
\ No newline at end of file
fileFormatVersion: 2 fileFormatVersion: 2
guid: 68aee830395e108478a9643b20de1689 guid: 8b3bc4d6f06a2d94baff2c7e3214a202
\ No newline at end of file \ No newline at end of file
using System.Collections.Generic;
using UnityEngine;
namespace com.al_arcade.shared
{
/// <summary>
/// Shuffled MCQ answers container.
/// answer1 from the API is ALWAYS correct — this shuffles
/// them for display and tracks which index is correct.
/// </summary>
public struct ShuffledMcq
{
/// <summary>The 4 answers in shuffled order.</summary>
public string[] answers;
/// <summary>Index (0-3) of the correct answer in the shuffled array.</summary>
public int correctIndex;
/// <summary>Check if player's chosen index is correct.</summary>
public bool IsCorrect(int chosenIndex) => chosenIndex == correctIndex;
/// <summary>The correct answer text.</summary>
public string CorrectText => answers[correctIndex];
}
public static class McqHelper
{
/// <summary>
/// Shuffles the 4 answers and returns which index is now correct.
/// </summary>
public static ShuffledMcq Shuffle(McqQuestion q)
{
// Build list with original indices
var list = new List<(string text, bool isCorrect)>
{
(q.answer1, true), // answer1 = ALWAYS correct
(q.answer2, false),
(q.answer3, false),
(q.answer4, false),
};
// Fisher-Yates shuffle
for (int i = list.Count - 1; i > 0; i--)
{
int j = Random.Range(0, i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
var result = new ShuffledMcq
{
answers = new string[4],
correctIndex = -1
};
for (int i = 0; i < 4; i++)
{
result.answers[i] = list[i].text;
if (list[i].isCorrect)
result.correctIndex = i;
}
return result;
}
}
}
\ No newline at end of file
fileFormatVersion: 2 fileFormatVersion: 2
guid: 8b4109c5dc3e965418f041d490b09d96 guid: 1bacf1883f436a74caa55a658a8746e3
\ No newline at end of file \ No newline at end of file
using System.Collections.Generic;
namespace com.al_arcade.shared
{
/// <summary>
/// Fluent builder for question filter parameters.
/// Used by GetMcq, GetTf, GetCs, GetMixed.
/// </summary>
public class QuestionFilter
{
private readonly Dictionary<string, string> _params = new();
public QuestionFilter CurriculumId(int id)
{
_params["curriculum_id"] = id.ToString();
return this;
}
public QuestionFilter SubjectId(int id)
{
_params["subject_id"] = id.ToString();
return this;
}
public QuestionFilter GradeId(int id)
{
_params["grade_id"] = id.ToString();
return this;
}
public QuestionFilter TermId(int id)
{
_params["term_id"] = id.ToString();
return this;
}
public QuestionFilter ChapterId(int id)
{
_params["chapter_id"] = id.ToString();
return this;
}
public QuestionFilter Difficulty(string diff)
{
if (!string.IsNullOrEmpty(diff)) _params["difficulty"] = diff;
return this;
}
public QuestionFilter BloomLevel(string bloom)
{
if (!string.IsNullOrEmpty(bloom)) _params["bloom_level"] = bloom;
return this;
}
public QuestionFilter VerifiedOnly(bool v = true)
{
if (v) _params["verified_only"] = "1";
return this;
}
public QuestionFilter Count(int n)
{
if (n > 0) _params["count"] = n.ToString();
return this;
}
public QuestionFilter Shuffle(bool s = true)
{
_params["shuffle"] = s ? "1" : "0";
return this;
}
// Mixed-specific counts
public QuestionFilter McqCount(int n)
{
if (n >= 0) _params["mcq_count"] = n.ToString();
return this;
}
public QuestionFilter TfCount(int n)
{
if (n >= 0) _params["tf_count"] = n.ToString();
return this;
}
public QuestionFilter CsCount(int n)
{
if (n >= 0) _params["cs_count"] = n.ToString();
return this;
}
public Dictionary<string, string> Build() => new(_params);
/// <summary>Quick shortcut: filter by chapter only.</summary>
public static QuestionFilter ForChapter(int chapterId, int count = 10)
{
return new QuestionFilter().ChapterId(chapterId).Count(count);
}
/// <summary>Quick shortcut: filter by subject + grade + term.</summary>
public static QuestionFilter ForSubject(int subjectId, int gradeId, int termId, int count = 10)
{
return new QuestionFilter()
.SubjectId(subjectId)
.GradeId(gradeId)
.TermId(termId)
.Count(count);
}
}
}
\ No newline at end of file
fileFormatVersion: 2 fileFormatVersion: 2
guid: cb8a50d45c5491e45a8c75803f8df92c guid: 52e062c4425a8ca4ea8b284d19376b38
\ No newline at end of file \ No newline at end of file
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text;
using UnityEngine; using UnityEngine;
using UnityEngine.Networking; using UnityEngine.Networking;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace com.al_arcade.shared namespace com.al_arcade.shared
{ {
/// <summary>
/// Science Street Question Bank API v3.0 Client.
/// Singleton MonoBehaviour — communicates with api.php.
/// </summary>
public class SSApiManager : MonoBehaviour public class SSApiManager : MonoBehaviour
{ {
public static SSApiManager Instance { get; private set; } public static SSApiManager Instance { get; private set; }
[Header("API Configuration")] [Header("API Configuration")]
[SerializeField] private string baseUrl = [SerializeField]
private string baseUrl =
"https://phpserver.caprover.al-arcade.com/ssbook/api.php"; "https://phpserver.caprover.al-arcade.com/ssbook/api.php";
[SerializeField] private int timeoutSeconds = 15; [SerializeField] private int timeoutSeconds = 15;
// ═══════════════════════════════════════════════════
// LIFECYCLE
// ═══════════════════════════════════════════════════
private void Awake() private void Awake()
{ {
if (Instance != null && Instance != this) if (Instance != null && Instance != this)
...@@ -31,7 +37,6 @@ namespace com.al_arcade.shared ...@@ -31,7 +37,6 @@ namespace com.al_arcade.shared
DontDestroyOnLoad(gameObject); DontDestroyOnLoad(gameObject);
} }
public static SSApiManager EnsureInstance() public static SSApiManager EnsureInstance()
{ {
if (Instance != null) return Instance; if (Instance != null) return Instance;
...@@ -41,8 +46,10 @@ namespace com.al_arcade.shared ...@@ -41,8 +46,10 @@ namespace com.al_arcade.shared
return Instance; return Instance;
} }
// ═══════════════════════════════════════════════════
public IEnumerator GetRequest(string action, // LOW-LEVEL HTTP HELPERS
// ═══════════════════════════════════════════════════
private IEnumerator GetRequest(string action,
Dictionary<string, string> parameters, Dictionary<string, string> parameters,
Action<string> onSuccess, Action<string> onSuccess,
Action<string> onError) Action<string> onError)
...@@ -65,171 +72,454 @@ namespace com.al_arcade.shared ...@@ -65,171 +72,454 @@ namespace com.al_arcade.shared
if (req.result != UnityWebRequest.Result.Success) if (req.result != UnityWebRequest.Result.Success)
{ {
string errorMsg = req.error ?? "Network error"; string errorMsg = req.error ?? "Network error";
Debug.LogWarning($"[SSApi] Request failed: {errorMsg}"); Debug.LogWarning($"[SSApi] GET failed: {errorMsg}\n URL: {url}");
onError?.Invoke(errorMsg); onError?.Invoke(errorMsg);
yield break; yield break;
} }
string json = req.downloadHandler.text; onSuccess?.Invoke(req.downloadHandler.text);
onSuccess?.Invoke(json);
} }
private IEnumerator PostForm(string action,
Dictionary<string, string> fields,
Action<string> onSuccess,
Action<string> onError)
{
var form = new WWWForm();
form.AddField("action", action);
if (fields != null)
{
foreach (var kv in fields)
form.AddField(kv.Key, kv.Value);
}
public IEnumerator Ping(Action<bool> onResult) using var req = UnityWebRequest.Post(baseUrl, form);
req.timeout = timeoutSeconds;
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{ {
yield return GetRequest("ping", null, Debug.LogWarning($"[SSApi] POST form failed: {req.error}");
json => onResult?.Invoke(true), onError?.Invoke(req.error ?? "Network error");
err => onResult?.Invoke(false)); yield break;
} }
onSuccess?.Invoke(req.downloadHandler.text);
}
public IEnumerator ValidateClassCode(string code, private IEnumerator PostJson(string action, string jsonBody,
Action<ClassInfo> onSuccess, Action<string> onSuccess,
Action<string> onError) Action<string> onError)
{ {
var p = new Dictionary<string, string> { { "class_code", code } }; string url = baseUrl + "?action=" + UnityWebRequest.EscapeURL(action);
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody);
yield return GetRequest("validate_class", p, using var req = new UnityWebRequest(url, "POST");
json => req.uploadHandler = new UploadHandlerRaw(bodyRaw);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.timeout = timeoutSeconds;
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[SSApi] POST json failed: {req.error}");
onError?.Invoke(req.error ?? "Network error");
yield break;
}
onSuccess?.Invoke(req.downloadHandler.text);
}
private T Parse<T>(string json) where T : class
{ {
try return JsonConvert.DeserializeObject<T>(json);
}
// ═══════════════════════════════════════════════════
// PING
// ═══════════════════════════════════════════════════
public IEnumerator Ping(Action<PingResponse> onSuccess,
Action<string> onError = null)
{ {
var resp = JsonConvert.DeserializeObject<ValidateClassResponse>(json); yield return GetRequest("ping", null,
if (resp.success && resp.classInfo != null) json =>
onSuccess?.Invoke(resp.classInfo); {
else var r = Parse<PingResponse>(json);
onError?.Invoke(resp.error ?? "Validation failed"); if (r != null && r.success) onSuccess?.Invoke(r);
else onError?.Invoke(r?.error ?? "Ping failed");
},
err => onError?.Invoke(err));
} }
catch (Exception e)
/// <summary>Simple bool ping — backwards compatible.</summary>
public IEnumerator Ping(Action<bool> onResult)
{ {
Debug.LogError($"[SSApi] Parse error: {e.Message}"); yield return Ping(
onError?.Invoke("Failed to parse server response"); _ => onResult?.Invoke(true),
_ => onResult?.Invoke(false));
} }
// ═══════════════════════════════════════════════════
// TAXONOMY: Curricula
// ═══════════════════════════════════════════════════
public IEnumerator GetCurricula(Action<CurriculumInfo[]> onSuccess,
Action<string> onError)
{
yield return GetRequest("get_curricula", null,
json =>
{
var r = Parse<CurriculaResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.curricula ?? Array.Empty<CurriculumInfo>());
else
onError?.Invoke(r?.error ?? "Failed to load curricula");
}, },
onError); onError);
} }
// ═══════════════════════════════════════════════════
// TAXONOMY: Grades
// ═══════════════════════════════════════════════════
public IEnumerator GetGrades(Action<GradeInfo[]> onSuccess, public IEnumerator GetGrades(Action<GradeInfo[]> onSuccess,
Action<string> onError) Action<string> onError)
{ {
yield return GetRequest("get_grades", null, yield return GetRequest("get_grades", null,
json => json =>
{ {
try var r = Parse<GradesResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.grades ?? Array.Empty<GradeInfo>());
else
onError?.Invoke(r?.error ?? "Failed to load grades");
},
onError);
}
// ═══════════════════════════════════════════════════
// TAXONOMY: Terms
// ═══════════════════════════════════════════════════
public IEnumerator GetTerms(Action<TermInfo[]> onSuccess,
Action<string> onError)
{ {
var resp = JsonConvert.DeserializeObject<GradesResponse>(json); yield return GetRequest("get_terms", null,
if (resp.success) json =>
onSuccess?.Invoke(resp.grades ?? Array.Empty<GradeInfo>()); {
var r = Parse<TermsResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.terms ?? Array.Empty<TermInfo>());
else else
onError?.Invoke(resp.error ?? "Failed to load grades"); onError?.Invoke(r?.error ?? "Failed to load terms");
},
onError);
} }
catch (Exception e)
// ═══════════════════════════════════════════════════
// TAXONOMY: Subjects
// ═══════════════════════════════════════════════════
public IEnumerator GetSubjects(Action<SubjectInfo[]> onSuccess,
Action<string> onError,
int curriculumId = 0)
{
var p = new Dictionary<string, string>();
if (curriculumId > 0)
p["curriculum_id"] = curriculumId.ToString();
yield return GetRequest("get_subjects", p,
json =>
{ {
onError?.Invoke("Parse error: " + e.Message); var r = Parse<SubjectsResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.subjects ?? Array.Empty<SubjectInfo>());
else
onError?.Invoke(r?.error ?? "Failed to load subjects");
},
onError);
} }
// ═══════════════════════════════════════════════════
// TAXONOMY: Chapters
// ═══════════════════════════════════════════════════
public IEnumerator GetChapters(Action<ChapterInfo[]> onSuccess,
Action<string> onError,
int subjectId = 0,
int gradeId = 0,
int termId = 0)
{
var p = new Dictionary<string, string>();
if (subjectId > 0) p["subject_id"] = subjectId.ToString();
if (gradeId > 0) p["grade_id"] = gradeId.ToString();
if (termId > 0) p["term_id"] = termId.ToString();
yield return GetRequest("get_chapters", p,
json =>
{
var r = Parse<ChaptersResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.chapters ?? Array.Empty<ChapterInfo>());
else
onError?.Invoke(r?.error ?? "Failed to load chapters");
}, },
onError); onError);
} }
// ═══════════════════════════════════════════════════
// TAXONOMY: Full Tree
// ═══════════════════════════════════════════════════
public IEnumerator GetTaxonomyTree(
Action<TreeCurriculum[]> onSuccess,
Action<string> onError,
int curriculumId = 0)
{
var p = new Dictionary<string, string>();
if (curriculumId > 0)
p["curriculum_id"] = curriculumId.ToString();
public IEnumerator FetchMcq(string buildType, string classCode, yield return GetRequest("get_taxonomy_tree", p,
int count, int gradeId, json =>
{
var r = Parse<TaxonomyTreeResponse>(json);
if (r is { success: true })
onSuccess?.Invoke(r.tree ?? Array.Empty<TreeCurriculum>());
else
onError?.Invoke(r?.error ?? "Failed to load taxonomy tree");
},
onError);
}
// ═══════════════════════════════════════════════════
// QUESTIONS: MCQ
// ═══════════════════════════════════════════════════
public IEnumerator FetchMcq(QuestionFilter filter,
Action<McqQuestion[]> onSuccess, Action<McqQuestion[]> onSuccess,
Action<string> onError) Action<string> onError)
{ {
var p = new Dictionary<string, string> yield return GetRequest("get_mcq", filter.Build(),
json =>
{ {
{ "build_type", buildType }, var r = Parse<McqResponse>(json);
{ "count", count.ToString() } if (r is { success: true } && r.questions != null)
}; onSuccess?.Invoke(r.questions);
if (gradeId > 0) p["grade_id"] = gradeId.ToString(); else
if (buildType == "teacher" && !string.IsNullOrEmpty(classCode)) onError?.Invoke(r?.error ?? "No MCQ questions returned");
p["class_code"] = classCode; },
onError);
}
yield return GetRequest("get_mcq", p, /// <summary>Quick overload: fetch MCQ by chapter id.</summary>
json => public IEnumerator FetchMcq(int chapterId, int count,
Action<McqQuestion[]> onSuccess,
Action<string> onError)
{ {
try yield return FetchMcq(
QuestionFilter.ForChapter(chapterId, count),
onSuccess, onError);
}
// ═══════════════════════════════════════════════════
// QUESTIONS: True/False
// ═══════════════════════════════════════════════════
public IEnumerator FetchTf(QuestionFilter filter,
Action<TfQuestion[]> onSuccess,
Action<string> onError)
{ {
var resp = JsonConvert.DeserializeObject<ApiResponse<McqQuestion>>(json); yield return GetRequest("get_tf", filter.Build(),
if (resp.success && resp.questions != null) json =>
onSuccess?.Invoke(resp.questions); {
var r = Parse<TfResponse>(json);
if (r is { success: true } && r.questions != null)
onSuccess?.Invoke(r.questions);
else else
onError?.Invoke(resp.error ?? "No questions returned"); onError?.Invoke(r?.error ?? "No TF questions returned");
},
onError);
} }
catch (Exception e)
/// <summary>Quick overload: fetch TF by chapter id.</summary>
public IEnumerator FetchTf(int chapterId, int count,
Action<TfQuestion[]> onSuccess,
Action<string> onError)
{ {
onError?.Invoke("Parse error: " + e.Message); yield return FetchTf(
QuestionFilter.ForChapter(chapterId, count),
onSuccess, onError);
} }
// ═══════════════════════════════════════════════════
// QUESTIONS: Correct the Sentence
// ═══════════════════════════════════════════════════
public IEnumerator FetchCs(QuestionFilter filter,
Action<CsQuestion[]> onSuccess,
Action<string> onError)
{
yield return GetRequest("get_cs", filter.Build(),
json =>
{
var r = Parse<CsResponse>(json);
if (r is { success: true } && r.questions != null)
onSuccess?.Invoke(r.questions);
else
onError?.Invoke(r?.error ?? "No CS questions returned");
}, },
onError); onError);
} }
/// <summary>Quick overload: fetch CS by chapter id.</summary>
public IEnumerator FetchCs(int chapterId, int count,
Action<CsQuestion[]> onSuccess,
Action<string> onError)
{
yield return FetchCs(
QuestionFilter.ForChapter(chapterId, count),
onSuccess, onError);
}
public IEnumerator FetchTf(string buildType, string classCode, // ═══════════════════════════════════════════════════
int count, int gradeId, // QUESTIONS: Mixed (MCQ + TF + CS interleaved)
Action<TfQuestion[]> onSuccess, // ═══════════════════════════════════════════════════
public IEnumerator FetchMixed(QuestionFilter filter,
Action<MixedResponse> onSuccess,
Action<string> onError) Action<string> onError)
{ {
var p = new Dictionary<string, string> yield return GetRequest("get_mixed", filter.Build(),
json =>
{ {
{ "build_type", buildType }, var r = Parse<MixedResponse>(json);
{ "count", count.ToString() } if (r is { success: true })
}; onSuccess?.Invoke(r);
if (gradeId > 0) p["grade_id"] = gradeId.ToString(); else
if (buildType == "teacher" && !string.IsNullOrEmpty(classCode)) onError?.Invoke(r?.error ?? "No mixed questions returned");
p["class_code"] = classCode; },
onError);
}
yield return GetRequest("get_tf", p, /// <summary>Quick overload for mixed by chapter.</summary>
json => public IEnumerator FetchMixed(int chapterId,
int mcqCount, int tfCount, int csCount,
Action<MixedResponse> onSuccess,
Action<string> onError)
{ {
try var filter = new QuestionFilter()
.ChapterId(chapterId)
.McqCount(mcqCount)
.TfCount(tfCount)
.CsCount(csCount);
yield return FetchMixed(filter, onSuccess, onError);
}
// ═══════════════════════════════════════════════════
// ANALYTICS: Report Single Attempt
// ═══════════════════════════════════════════════════
public IEnumerator ReportAttempt(AttemptData attempt,
Action<ReportAttemptResponse> onSuccess = null,
Action<string> onError = null)
{
string json = JsonConvert.SerializeObject(attempt,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
yield return PostJson("report_attempt", json,
text =>
{ {
var resp = JsonConvert.DeserializeObject<ApiResponse<TfQuestion>>(json); var r = Parse<ReportAttemptResponse>(text);
if (resp.success && resp.questions != null) if (r is { success: true })
onSuccess?.Invoke(resp.questions); onSuccess?.Invoke(r);
else else
onError?.Invoke(resp.error ?? "No questions returned"); onError?.Invoke(r?.error ?? "Failed to report attempt");
},
onError);
} }
catch (Exception e)
{ /// <summary>Quick overload: report with minimal data.</summary>
onError?.Invoke("Parse error: " + e.Message); public IEnumerator ReportAttempt(string questionType, int questionId,
string sessionId, bool isCorrect,
int timeTakenMs,
Action<ReportAttemptResponse> onSuccess = null,
Action<string> onError = null)
{
var attempt = new AttemptData(questionType, questionId,
sessionId, isCorrect, timeTakenMs);
yield return ReportAttempt(attempt, onSuccess, onError);
} }
// ═══════════════════════════════════════════════════
// ANALYTICS: Report Batch of Attempts
// ═══════════════════════════════════════════════════
public IEnumerator ReportBatch(AttemptData[] attempts,
Action<ReportBatchResponse> onSuccess = null,
Action<string> onError = null)
{
var payload = new BatchAttemptPayload { attempts = attempts };
string json = JsonConvert.SerializeObject(payload,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
yield return PostJson("report_batch", json,
text =>
{
var r = Parse<ReportBatchResponse>(text);
if (r is { success: true })
onSuccess?.Invoke(r);
else
onError?.Invoke(r?.error ?? "Failed to report batch");
}, },
onError); onError);
} }
// ═══════════════════════════════════════════════════
// ANALYTICS: Flag / Report a Question
// ═══════════════════════════════════════════════════
public IEnumerator ReportQuestion(string questionType, int questionId,
string reason, string detail = null,
string sessionId = null,
string reporterId = null,
Action<ReportQuestionResponse> onSuccess = null,
Action<string> onError = null)
{
var fields = new Dictionary<string, string>
{
{ "question_type", questionType },
{ "question_id", questionId.ToString() },
{ "reason", reason }
};
if (!string.IsNullOrEmpty(detail)) fields["detail"] = detail;
if (!string.IsNullOrEmpty(sessionId)) fields["session_id"] = sessionId;
if (!string.IsNullOrEmpty(reporterId)) fields["reporter_id"] = reporterId;
public IEnumerator FetchCs(string buildType, string classCode, yield return PostForm("report_question", fields,
int count, int gradeId, text =>
Action<CsQuestion[]> onSuccess, {
var r = Parse<ReportQuestionResponse>(text);
if (r is { success: true })
onSuccess?.Invoke(r);
else
onError?.Invoke(r?.error ?? "Failed to report question");
},
onError);
}
// ═══════════════════════════════════════════════════
// ANALYTICS: Get Question Stats
// ═══════════════════════════════════════════════════
public IEnumerator GetQuestionStats(string questionType, int questionId,
Action<QuestionStatsResponse> onSuccess,
Action<string> onError) Action<string> onError)
{ {
var p = new Dictionary<string, string> var p = new Dictionary<string, string>
{ {
{ "build_type", buildType }, { "question_type", questionType },
{ "count", count.ToString() } { "question_id", questionId.ToString() }
}; };
if (gradeId > 0) p["grade_id"] = gradeId.ToString();
if (buildType == "teacher" && !string.IsNullOrEmpty(classCode))
p["class_code"] = classCode;
yield return GetRequest("get_cs", p, yield return GetRequest("get_question_stats", p,
json => json =>
{ {
try var r = Parse<QuestionStatsResponse>(json);
{ if (r is { success: true })
var resp = JsonConvert.DeserializeObject<ApiResponse<CsQuestion>>(json); onSuccess?.Invoke(r);
if (resp.success && resp.questions != null)
onSuccess?.Invoke(resp.questions);
else else
onError?.Invoke(resp.error ?? "No questions returned"); onError?.Invoke(r?.error ?? "Failed to load stats");
}
catch (Exception e)
{
onError?.Invoke("Parse error: " + e.Message);
}
}, },
onError); onError);
} }
......
using System; using System;
using UnityEngine;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace com.al_arcade.shared namespace com.al_arcade.shared
{ {
// ═══════════════════════════════════════════════════════
// BASE RESPONSE
// ═══════════════════════════════════════════════════════
[Serializable] [Serializable]
public class ApiResponse<T> public class ApiBaseResponse
{ {
public bool success; public bool success;
public string error; public string error;
public string api_version;
}
// ═══════════════════════════════════════════════════════
// PING
// ═══════════════════════════════════════════════════════
[Serializable]
public class PingResponse : ApiBaseResponse
{
public string message;
public string version;
public string db;
public string time;
}
// ═══════════════════════════════════════════════════════
// TAXONOMY: Curriculum
// ═══════════════════════════════════════════════════════
[Serializable]
public class CurriculumInfo
{
public int id;
public string code;
public string name_ar;
public string name_en;
public string question_lang;
public int subject_count;
}
[Serializable]
public class CurriculaResponse : ApiBaseResponse
{
public int count; public int count;
public T[] questions; public CurriculumInfo[] curricula;
}
// ═══════════════════════════════════════════════════════
// TAXONOMY: Grade
// ═══════════════════════════════════════════════════════
[Serializable]
public class GradeInfo
{
public int id;
public string name_ar;
public string name_en;
public int sort_order;
} }
[Serializable] [Serializable]
public class GradesResponse public class GradesResponse : ApiBaseResponse
{ {
public bool success; public int count;
public string error;
public GradeInfo[] grades; public GradeInfo[] grades;
} }
// ═══════════════════════════════════════════════════════
// TAXONOMY: Term
// ═══════════════════════════════════════════════════════
[Serializable] [Serializable]
public class ValidateClassResponse public class TermInfo
{ {
public bool success; public int id;
public string error; public string name_ar;
public string name_en;
public int sort_order;
}
[JsonProperty("class")] [Serializable]
public ClassInfo classInfo; public class TermsResponse : ApiBaseResponse
{
public int count;
public TermInfo[] terms;
} }
// ═══════════════════════════════════════════════════════
// TAXONOMY: Subject
// ═══════════════════════════════════════════════════════
[Serializable]
public class SubjectInfo
{
public int id;
public string name_ar;
public string name_en;
public string icon;
public string color_hex;
public int curriculum_id;
public string curriculum_code;
public string curriculum_ar;
public string curriculum_en;
public string question_lang;
}
[Serializable] [Serializable]
public class GradeInfo public class SubjectsResponse : ApiBaseResponse
{ {
public string id; public int count;
public string name; public SubjectInfo[] subjects;
}
// ═══════════════════════════════════════════════════════
// TAXONOMY: Chapter
// ═══════════════════════════════════════════════════════
[Serializable]
public class ChapterInfo
{
public int id;
public string name_ar;
public string name_en;
public int subject_id;
public string subject_ar;
public string subject_en;
public int grade_id;
public string grade_ar;
public string grade_en;
public int term_id;
public string term_ar;
public string term_en;
public int curriculum_id;
public string curriculum_code;
public string question_lang;
public int mcq_count;
public int tf_count;
public int cs_count;
public int total_questions;
}
[Serializable]
public class ChaptersResponse : ApiBaseResponse
{
public int count;
public ChapterInfo[] chapters;
}
// ═══════════════════════════════════════════════════════
// TAXONOMY: Full Tree
// ═══════════════════════════════════════════════════════
[Serializable]
public class TreeChapter
{
public int id;
public string name_ar;
public string name_en;
public int grade_id;
public int term_id;
public string grade_ar;
public string grade_en;
public string term_ar;
public string term_en;
public int mcq;
public int tf;
public int cs;
public int total;
}
[Serializable]
public class TreeSubject
{
public int id;
public string name_ar;
public string name_en;
public string icon;
public string color_hex;
public TreeChapter[] chapters;
public int total_questions;
} }
[Serializable] [Serializable]
public class ClassInfo public class TreeCurriculum
{ {
public string id; public int id;
public string class_name; public string code;
public string class_code; public string name_ar;
public string is_active; public string name_en;
public string grade_id; public string question_lang;
public string grade_name; public TreeSubject[] subjects;
public string teacher_name; public int total_questions;
} }
[Serializable]
public class TaxonomyTreeResponse : ApiBaseResponse
{
public TreeCurriculum[] tree;
}
// ═══════════════════════════════════════════════════════
// SHARED: Taxonomy context embedded in questions
// ═══════════════════════════════════════════════════════
[Serializable] [Serializable]
public class McqQuestion public class QuestionTaxonomy
{ {
public string id; public int chapter_id;
public string chapter_ar;
public string chapter_en;
public int subject_id;
public string subject_ar;
public string subject_en;
public int grade_id;
public string grade_ar;
public string grade_en;
public int term_id;
public string term_ar;
public string term_en;
public int curriculum_id;
public string curriculum_code;
public string question_lang;
}
// ═══════════════════════════════════════════════════════
// MCQ Question
// ═══════════════════════════════════════════════════════
[Serializable]
public class McqQuestion : QuestionTaxonomy
{
public int id;
public string question_text; public string question_text;
public Sprite questionImage;
public string answer1; public string answer1;
public Sprite answer1Image;
public string answer2; public string answer2;
public Sprite answer2Image;
public string answer3; public string answer3;
public Sprite answer3Image;
public string answer4; public string answer4;
public Sprite answer4Image; public string explanation;
public string difficulty;
public string bloom_level;
public int? time_limit_s;
public string question_image;
public string answer1_image;
public string answer2_image;
public string answer3_image;
public string answer4_image;
public string explanation_image;
public string question_audio;
public string source; public string source;
public string grade_name; public float? accuracy_rate;
public int? avg_time_ms;
// Private shuffle state (keeps track of current shuffle) public float? auto_difficulty;
private int[] _shuffleIndices; }
private int _cachedCorrectIndex = -1;
/// <summary> [Serializable]
/// Original method - unchanged for backwards compatibility public class McqResponse : ApiBaseResponse
/// </summary>
public string[] GetShuffledAnswers(out int correctIndex)
{ {
string[] arr = { answer1, answer2, answer3, answer4 }; public string type;
string correct = answer1; public int count;
public McqQuestion[] questions;
}
// Generate and cache shuffle indices // ═══════════════════════════════════════════════════════
_shuffleIndices = new int[] { 0, 1, 2, 3 }; // TF Question
for (int i = _shuffleIndices.Length - 1; i > 0; i--) // ═══════════════════════════════════════════════════════
[Serializable]
public class TfQuestion : QuestionTaxonomy
{ {
int j = UnityEngine.Random.Range(0, i + 1); public int id;
(_shuffleIndices[i], _shuffleIndices[j]) = (_shuffleIndices[j], _shuffleIndices[i]); public string question_text;
public bool is_true;
public string explanation;
public string difficulty;
public string bloom_level;
public int? time_limit_s;
public string question_image;
public string question_audio;
public string explanation_image;
public string source;
public float? accuracy_rate;
public int? avg_time_ms;
public float? auto_difficulty;
} }
// Apply shuffle [Serializable]
string[] shuffled = new string[4]; public class TfResponse : ApiBaseResponse
for (int i = 0; i < 4; i++)
{ {
shuffled[i] = arr[_shuffleIndices[i]]; public string type;
public int count;
public TfQuestion[] questions;
} }
correctIndex = Array.IndexOf(shuffled, correct); // ═══════════════════════════════════════════════════════
_cachedCorrectIndex = correctIndex; // CS (Correct the Sentence) Question
return shuffled; // ═══════════════════════════════════════════════════════
[Serializable]
public class CsWord
{
public string word_text;
public int position;
public bool is_distractor;
} }
/// <summary> [Serializable]
/// NEW: Get shuffled answers WITH images public class CsOption
/// Returns answer text + images in the same shuffled order
/// </summary>
public void GetShuffledAnswersWithImages(
out string[] answers,
out Sprite[] images,
out int correctIndex)
{ {
string[] textArr = { answer1, answer2, answer3, answer4 }; public string option_text;
Sprite[] imageArr = { answer1Image, answer2Image, answer3Image, answer4Image }; public bool is_correct;
string correct = answer1; }
// Generate shuffle indices if not already done [Serializable]
if (_shuffleIndices == null || _shuffleIndices.Length == 0) public class CsQuestion : QuestionTaxonomy
{
_shuffleIndices = new int[] { 0, 1, 2, 3 };
for (int i = _shuffleIndices.Length - 1; i > 0; i--)
{ {
int j = UnityEngine.Random.Range(0, i + 1); public int id;
(_shuffleIndices[i], _shuffleIndices[j]) = (_shuffleIndices[j], _shuffleIndices[i]); public string instruction_text;
} public string explanation;
public string difficulty;
public string bloom_level;
public int? time_limit_s;
public string question_image;
public string explanation_image;
public string source;
public float? accuracy_rate;
public int? avg_time_ms;
public float? auto_difficulty;
public CsWord[] words;
public CsOption[] options;
public string distractor_word;
public string correct_answer;
public string sentence;
} }
// Apply shuffle to both text and images [Serializable]
string[] shuffledText = new string[4]; public class CsResponse : ApiBaseResponse
Sprite[] shuffledImages = new Sprite[4];
for (int i = 0; i < 4; i++)
{ {
int originalIndex = _shuffleIndices[i]; public string type;
shuffledText[i] = textArr[originalIndex]; public int count;
shuffledImages[i] = imageArr[originalIndex]; public CsQuestion[] questions;
} }
answers = shuffledText; // ═══════════════════════════════════════════════════════
images = shuffledImages; // MIXED Question (interleaved)
correctIndex = Array.IndexOf(shuffledText, correct); // ═══════════════════════════════════════════════════════
_cachedCorrectIndex = correctIndex; [Serializable]
public class MixedByType
{
public McqQuestion[] mcq;
public TfQuestion[] tf;
public CsQuestion[] cs;
} }
/// <summary> /// <summary>
/// HELPER: Get image for a specific answer index (after shuffle) /// A single item in the interleaved array.
/// Useful if you only have the index /// Use _type to determine which fields are populated.
/// </summary> /// </summary>
public Sprite GetAnswerImageByIndex(int shuffledIndex) [Serializable]
public class MixedQuestion : QuestionTaxonomy
{ {
if (_shuffleIndices == null || _shuffleIndices.Length == 0) public string _type; // "mcq", "tf", "cs"
return null; public int id;
int originalIndex = _shuffleIndices[shuffledIndex]; // MCQ fields
Sprite[] allImages = { answer1Image, answer2Image, answer3Image, answer4Image }; public string question_text;
return allImages[originalIndex]; public string answer1;
public string answer2;
public string answer3;
public string answer4;
public string question_image;
public string answer1_image;
public string answer2_image;
public string answer3_image;
public string answer4_image;
// TF fields
public bool is_true;
// CS fields
public string instruction_text;
public CsWord[] words;
public CsOption[] options;
public string sentence;
public string distractor_word;
public string correct_answer;
// Shared
public string explanation;
public string difficulty;
public int? time_limit_s;
} }
/// <summary> [Serializable]
/// HELPER: Get the cached correct index from last shuffle public class MixedResponse : ApiBaseResponse
/// </summary>
public int GetCachedCorrectIndex()
{ {
return _cachedCorrectIndex; public int total;
public MixedByType by_type;
public MixedQuestion[] interleaved;
} }
/// <summary> // ═══════════════════════════════════════════════════════
/// HELPER: Reset shuffle state (call before new question) // ANALYTICS: Report Attempt Response
/// </summary> // ═══════════════════════════════════════════════════════
public void ResetShuffle() [Serializable]
public class ReportAttemptResponse : ApiBaseResponse
{ {
_shuffleIndices = null; public int attempt_id;
_cachedCorrectIndex = -1; public bool recorded;
}
} }
[Serializable]
public class ReportBatchResponse : ApiBaseResponse
{
public int recorded;
public int total;
public string[] errors;
}
// ═══════════════════════════════════════════════════════
// ANALYTICS: Report Question (flag) Response
// ═══════════════════════════════════════════════════════
[Serializable] [Serializable]
public class TfQuestion public class ReportQuestionResponse : ApiBaseResponse
{ {
public string id; public int report_id;
public string question_text; public string message;
public bool is_true;
public string source;
public string grade_name;
} }
// ═══════════════════════════════════════════════════════
// ANALYTICS: Question Stats
// ═══════════════════════════════════════════════════════
[Serializable]
public class QuestionStats
{
public int times_served;
public int times_correct;
public int times_wrong;
public int times_skipped;
public int times_timeout;
public int? avg_time_ms;
public float? accuracy_rate;
public float? completion_rate;
public float? difficulty_rating;
public float? skip_rate;
public float? timeout_rate;
public string first_served_at;
public string last_served_at;
}
[Serializable] [Serializable]
public class CsQuestion public class AnswerDistribution
{ {
public string id; public int answer_index;
public string source; public int times_selected;
public string grade_name; public int? avg_time_ms;
public string wrong_word; public int selected_by_top_quartile;
public string correct_answer; public int selected_by_bottom_quartile;
public CsWord[] words;
public CsOption[] options;
} }
[Serializable] [Serializable]
public class CsWord public class DailyTrend
{ {
public string word_text; public string stat_date;
public int position; public int times_served;
public bool is_wrong; public int times_correct;
public int times_wrong;
public int? avg_time_ms;
} }
[Serializable] [Serializable]
public class CsOption public class QuestionStatsResponse : ApiBaseResponse
{ {
public string option_text; public QuestionStats stats;
public bool is_correct; public DailyTrend[] daily_trend;
public AnswerDistribution[] answer_distribution;
public string message;
} }
} }
\ No newline at end of file
...@@ -59,30 +59,31 @@ namespace com.al_arcade.shared ...@@ -59,30 +59,31 @@ namespace com.al_arcade.shared
{ {
var api = SSApiManager.EnsureInstance(); var api = SSApiManager.EnsureInstance();
yield return api.ValidateClassCode(code, // yield return api.ValidateClassCode(code,
cls => // cls =>
{ // {
classCode = cls.class_code; // classCode = cls.class_code;
className = cls.class_name; // className = cls.class_name;
teacherName = cls.teacher_name; // teacherName = cls.teacher_name;
gradeName = cls.grade_name; // gradeName = cls.grade_name;
buildType = "teacher"; // buildType = "teacher";
isClassValidated = true; // isClassValidated = true;
if (int.TryParse(cls.grade_id, out int gid)) // if (int.TryParse(cls.grade_id, out int gid))
gradeId = gid; // gradeId = gid;
Debug.Log($"[SSSession] Teacher session started: {className} by {teacherName}"); // Debug.Log($"[SSSession] Teacher session started: {className} by {teacherName}");
onSuccess?.Invoke(); // onSuccess?.Invoke();
onSessionReady?.Invoke(); // onSessionReady?.Invoke();
}, // },
err => // err =>
{ // {
isClassValidated = false; // isClassValidated = false;
Debug.LogWarning($"[SSSession] Validation failed: {err}"); // Debug.LogWarning($"[SSSession] Validation failed: {err}");
onError?.Invoke(err); // onError?.Invoke(err);
onSessionError?.Invoke(err); // onSessionError?.Invoke(err);
}); // });
yield break;
} }
......
...@@ -396,22 +396,7 @@ namespace com.al_arcade.tf ...@@ -396,22 +396,7 @@ namespace com.al_arcade.tf
{ {
return new TfQuestion[] return new TfQuestion[]
{ {
new() { id="1", question_text="الشمس تدور حول الأرض",
is_true=false, source="علوم" },
new() { id="2", question_text="الماء يتكون من الهيدروجين والأكسجين",
is_true=true, source="كيمياء" },
new() { id="3", question_text="القمر ينير بذاته",
is_true=false, source="فلك" },
new() { id="4", question_text="النباتات تحتاج ضوء الشمس لعملية البناء الضوئي",
is_true=true, source="أحياء" },
new() { id="5", question_text="عدد قارات العالم خمس قارات",
is_true=false, source="جغرافيا" },
new() { id="6", question_text="الحديد يتمدد بالحرارة",
is_true=true, source="فيزياء" },
new() { id="7", question_text="المحيط الأطلسي هو أكبر محيطات العالم",
is_true=false, source="جغرافيا" },
new() { id="8", question_text="جسم الإنسان يحتوي على 206 عظمة",
is_true=true, source="أحياء" },
}; };
} }
......
...@@ -54,9 +54,15 @@ namespace com.al_arcade.tf ...@@ -54,9 +54,15 @@ namespace com.al_arcade.tf
{ {
var session = SSGameSession.EnsureInstance(); var session = SSGameSession.EnsureInstance();
var api = SSApiManager.EnsureInstance(); var api = SSApiManager.EnsureInstance();
var filter = new QuestionFilter()
.CurriculumId(0)
.SubjectId(0)
.GradeId(session.gradeId)
.Count(session.questionCount);
yield return api.FetchTf( yield return api.FetchTf(
session.buildType, session.classCode, filter,
session.questionCount, session.gradeId,
qs => _questions = qs, qs => _questions = qs,
err => onError(err) err => onError(err)
); );
......
...@@ -320,16 +320,7 @@ namespace com.al_arcade.tf ...@@ -320,16 +320,7 @@ namespace com.al_arcade.tf
{ {
return new TfQuestion[] return new TfQuestion[]
{ {
new() { id="1", question_text="الشمس تدور حول الأرض",
is_true=false, source="علوم" },
new() { id="2", question_text="الماء يتكون من الهيدروجين والأكسجين",
is_true=true, source="كيمياء" },
new() { id="3", question_text="القمر ينير بذاته",
is_true=false, source="فلك" },
new() { id="4", question_text="النباتات تحتاج ضوء الشمس لعملية البناء الضوئي",
is_true=true, source="أحياء" },
new() { id="5", question_text="الحديد يتمدد بالحرارة",
is_true=true, source="فيزياء" },
}; };
} }
} }
......
...@@ -13,7 +13,7 @@ PlayerSettings: ...@@ -13,7 +13,7 @@ PlayerSettings:
useOnDemandResources: 0 useOnDemandResources: 0
accelerometerFrequency: 60 accelerometerFrequency: 60
companyName: DefaultCompany companyName: DefaultCompany
productName: Correct The Sentence productName: SSBookMinigames
defaultCursor: {fileID: 0} defaultCursor: {fileID: 0}
cursorHotspot: {x: 0, y: 0} cursorHotspot: {x: 0, y: 0}
m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1}
...@@ -170,7 +170,7 @@ PlayerSettings: ...@@ -170,7 +170,7 @@ PlayerSettings:
androidMaxAspectRatio: 2.4 androidMaxAspectRatio: 2.4
androidMinAspectRatio: 1 androidMinAspectRatio: 1
applicationIdentifier: applicationIdentifier:
Android: com.DefaultCompany.CorrectTheSentence Android: com.DefaultCompany.SSBookMinigames
Standalone: com.Unity-Technologies.com.unity.template.urp-blank Standalone: com.Unity-Technologies.com.unity.template.urp-blank
iPhone: com.Unity-Technologies.com.unity.template.urp-blank iPhone: com.Unity-Technologies.com.unity.template.urp-blank
buildNumber: buildNumber:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment