Hướng dẫn: dựng luồng đăng nhập
Trong hướng dẫn này bạn dựng scene Login từ đầu: scene chờ bước kiểm tra server, hiện các nút đăng nhập, đăng nhập cho người chơi và mở Lobby. Cuối cùng, bạn thêm một nút đăng xuất để đưa người chơi quay lại. Bạn sẽ dùng năm thứ:
IProjectVStartupPresenter: khởi động game và báo từng bước;ProjectVStartupSnapshot: bước cần vẽ;IProjectVAuthenticationPresenter: các command (lệnh) đăng nhập và trạng thái;IProjectVGuestIdentityProvider: Guest ID của thiết bị này;IProjectVAccountPresenter: đăng xuất từ Lobby.
Trước khi bắt đầu: SDK đã được cài và root của nó nằm trong scene Login
(Cài đặt và thiết lập,
Một root trong scene Login), và scene
Lobby có một scope kế thừa từ ProjectVAuthenticatedSceneScope
(Tích hợp scene).
Tutorial trước, Dựng màn Shop, cho thấy pattern theo dõi rồi
dispose (giải phóng) được dùng lại ở đây.
1. Đăng ký trang
Trang là một MonoBehaviour trong scene Login. Cho scene scope kế thừa từ
ProjectVLoginSceneScope
và đăng ký trang trong ConfigureLogin:
public sealed class LoginSceneScope : ProjectVLoginSceneScope
{
protected override void ConfigureLogin(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<LoginPage>();
}
}
2. Nhận các presenter
Login không có screen contract: trang dùng trực tiếp các presenter. Xin cả ba
presenter trong một method [Inject] và giữ chúng trong các field.
[Inject]
public void Construct(
IProjectVStartupPresenter startupPresenter,
IProjectVAuthenticationPresenter authenticationPresenter,
IProjectVGuestIdentityProvider guestIdentityProvider)
{
startup = startupPresenter ?? throw new ArgumentNullException(nameof(startupPresenter));
authentication = authenticationPresenter ?? throw new ArgumentNullException(nameof(authenticationPresenter));
guestIdentity = guestIdentityProvider ?? throw new ArgumentNullException(nameof(guestIdentityProvider));
}
3. Đăng ký nhận khi trang đang mở
SubscribeAsync(listener) gọi listener ngay lập tức với trạng thái hiện tại,
rồi gọi lại mỗi khi có thay đổi. Nó trả về một IDisposable: dispose nó khi
trang đóng. Listener có thể chạy ngoài Unity main thread, nên nó chỉ khởi chạy
một method chuyển sang main thread trước tiên.
private void OnStartupChanged(ProjectVStartupSnapshot snapshot) => RenderStartupAsync(snapshot).Forget();
private async UniTaskVoid RenderStartupAsync(ProjectVStartupSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
// Draw the step (step 5).
}
Làm tương tự cho Authentication presenter với OnAuthenticationChanged.
Trong OnDisable, dispose cả hai subscription và huỷ một
CancellationTokenSource mà bạn truyền vào mọi command.
4. Khởi động khi content đã sẵn sàng
Gọi StartAsync(contentReady: true, token) khi content của riêng bạn
(Addressables, localization) đã tải xong. Trước đó, quá trình khởi động chờ ở
WaitingForContent.
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
var startupSub = await startup.SubscribeAsync(OnStartupChanged);
var authenticationSub = await authentication.SubscribeAsync(OnAuthenticationChanged);
if (token.IsCancellationRequested)
{
// The page closed while subscribing.
startupSub.Dispose();
authenticationSub.Dispose();
return;
}
startupSubscription = startupSub;
authenticationSubscription = authenticationSub;
await LoadContentAsync(token);
if (!token.IsCancellationRequested)
{
await startup.StartAsync(contentReady: true, token);
}
}
Bạn không cần kết quả trả về: listener thấy mọi bước. Token chỉ dừng việc chờ của bạn; bản thân quá trình khởi động vẫn tiếp tục chạy.
5. Vẽ từng bước khởi động
Chọn panel theo snapshot: IsReady mở Lobby, RequiresLogin
(AwaitingLogin) hiện các nút đăng nhập, IsBlocking hiện một màn chặn, và
mọi trường hợp khác hiện màn đang tải.
if (snapshot.IsReady)
{
OpenLobby();
return;
}
loadingPanel.SetActive(!snapshot.RequiresLogin && !snapshot.IsBlocking);
loginPanel.SetActive(snapshot.RequiresLogin);
blockingPanel.SetActive(snapshot.IsBlocking);
loadingText.text = snapshot.Status == ProjectVStartupStatus.Admitting ? "Connecting…" : "Loading…";
if (snapshot.IsBlocking)
{
ShowBlocking(snapshot);
}
Các status chặn là Maintenance, UpdateRequired, ContractMismatch,
Offline và Failed. Hiện một thông báo cho mỗi status, và một nút thử lại
(Retry) gọi lại StartAsync(true, token). Ẩn Retry với UpdateRequired và
ContractMismatch: người chơi phải cập nhật game. Khi Error.RequestId có
giá trị, hiện nó lên màn để hỗ trợ. Không bao giờ mở Lobby khi đang ở một
status chặn.
Trong game thật, khi Error có giá trị, lấy text từ bảng localization của bạn
bằng snapshot.Error.LocalizationKey (projectv.startup.<category>).
6. Đăng nhập bằng Guest
Lấy Guest ID của thiết bị này từ IProjectVGuestIdentityProvider, rồi gọi
AuthenticateDeviceAsync. Truyền null cho options. Luôn dùng ID của
provider, nếu không người chơi sẽ nhận một tài khoản Guest mới.
public async void OnGuestClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var id = await guestIdentity.GetOrCreateAsync(token);
var result = await authentication.AuthenticateDeviceAsync(id, null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
Listener authentication tắt các nút khi IsBusy và hiện Error khi đăng nhập
thất bại. Lỗi Cancelled nghĩa là người chơi đã đóng một prompt hoặc trang đã
đóng: không hiện gì cả.
7. Thêm nút Google
Chỉ hiện nút Google khi SDK cho biết Google dùng được ở đây. Quyết định dựa
trên GetNativeAvailability, không bao giờ dựa trên Unity platform.
googleButton.gameObject.SetActive(
authentication.GetNativeAvailability(ProjectVNativeAuthenticationProvider.Google).IsAvailable);
Handler click của nó giống handler Guest, chỉ thay hai lệnh gọi Guest bằng
authentication.SignInWithGoogleAsync(null, token). Google cũng cần asset
thiết lập của nó; xem
Google và Apple.
8. Chuyển giao cho quá trình khởi động
Đăng nhập thành công không tải dữ liệu của người chơi. Kiểm tra
result.Snapshot.IsAuthenticated (không phải IsSuccess), rồi gọi lại
StartAsync. Listener khởi động đi qua Hydrating tới Ready, và bước 5 mở
Lobby.
private async Task ContinueAfterSignInAsync(ProjectVAuthenticationCommandResult result, CancellationToken token)
{
if (this == null || !result.Snapshot.IsAuthenticated)
{
return; // the authentication listener shows the error
}
await startup.StartAsync(contentReady: true, token);
}
Không bao giờ mở Lobby ngay sau khi đăng nhập: hãy chờ Ready.
9. Đăng xuất từ Lobby
Thêm một AccountPopup vào scene Lobby và đăng ký nó trong
ConfigureAuthenticated của scope Lobby:
builder.RegisterComponentInHierarchy<AccountPopup>();
Nút Logout của nó gọi IProjectVAccountPresenter.LogoutAsync(token). SDK
đăng xuất người chơi và xoá dữ liệu của tài khoản, rồi gửi một snapshot với
RequiresLoginNavigation là true. Tải Login từ listener khi snapshot đó tới:
if (snapshot.RequiresLoginNavigation)
{
OpenLogin();
return;
}
Khi quay lại Login, OnEnable gọi lại StartAsync; nếu không có phiên đăng
nhập đã lưu, quá trình khởi động dừng ở AwaitingLogin và hiện các nút.
Toàn bộ code
using SilverTiger.ProjectV.Unity.Presentation;
using VContainer;
using VContainer.Unity;
public sealed class LoginSceneScope : ProjectVLoginSceneScope
{
protected override void ConfigureLogin(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<LoginPage>();
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Authentication;
using SilverTiger.ProjectV.Unity.Presentation.Startup;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using VContainer;
public sealed class LoginPage : MonoBehaviour
{
private const string LobbyScene = "Lobby";
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMP_Text loadingText = null!;
[SerializeField] private GameObject loginPanel = null!;
[SerializeField] private Button guestButton = null!;
[SerializeField] private Button googleButton = null!;
[SerializeField] private TMP_Text loginErrorText = null!;
[SerializeField] private GameObject blockingPanel = null!;
[SerializeField] private TMP_Text blockingText = null!;
[SerializeField] private Button retryButton = null!;
private IProjectVStartupPresenter startup = null!;
private IProjectVAuthenticationPresenter authentication = null!;
private IProjectVGuestIdentityProvider guestIdentity = null!;
private IDisposable? startupSubscription;
private IDisposable? authenticationSubscription;
private CancellationTokenSource? lifetime;
private bool lobbyRequested;
[Inject]
public void Construct(
IProjectVStartupPresenter startupPresenter,
IProjectVAuthenticationPresenter authenticationPresenter,
IProjectVGuestIdentityProvider guestIdentityProvider)
{
startup = startupPresenter ?? throw new ArgumentNullException(nameof(startupPresenter));
authentication = authenticationPresenter ?? throw new ArgumentNullException(nameof(authenticationPresenter));
guestIdentity = guestIdentityProvider ?? throw new ArgumentNullException(nameof(guestIdentityProvider));
}
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
googleButton.gameObject.SetActive(
authentication.GetNativeAvailability(ProjectVNativeAuthenticationProvider.Google).IsAvailable);
var startupSub = await startup.SubscribeAsync(OnStartupChanged);
var authenticationSub = await authentication.SubscribeAsync(OnAuthenticationChanged);
if (token.IsCancellationRequested)
{
// The page closed while subscribing.
startupSub.Dispose();
authenticationSub.Dispose();
return;
}
startupSubscription = startupSub;
authenticationSubscription = authenticationSub;
await LoadContentAsync(token);
if (!token.IsCancellationRequested)
{
await startup.StartAsync(contentReady: true, token);
}
}
private void OnDisable()
{
startupSubscription?.Dispose();
startupSubscription = null;
authenticationSubscription?.Dispose();
authenticationSubscription = null;
lifetime?.Cancel();
lifetime?.Dispose();
lifetime = null;
}
// Load your Addressables, localization and so on here.
private static Task LoadContentAsync(CancellationToken token)
{
return Task.CompletedTask;
}
// May be called off the Unity main thread.
private void OnStartupChanged(ProjectVStartupSnapshot snapshot) => RenderStartupAsync(snapshot).Forget();
private async UniTaskVoid RenderStartupAsync(ProjectVStartupSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
if (snapshot.IsReady)
{
OpenLobby();
return;
}
loadingPanel.SetActive(!snapshot.RequiresLogin && !snapshot.IsBlocking);
loginPanel.SetActive(snapshot.RequiresLogin);
blockingPanel.SetActive(snapshot.IsBlocking);
loadingText.text = snapshot.Status == ProjectVStartupStatus.Admitting ? "Connecting…" : "Loading…";
if (snapshot.IsBlocking)
{
ShowBlocking(snapshot);
}
}
private void ShowBlocking(ProjectVStartupSnapshot snapshot)
{
var message = snapshot.Status switch
{
ProjectVStartupStatus.Maintenance => "The server is under maintenance. Please try again later.",
ProjectVStartupStatus.UpdateRequired => "A new version is available. Please update the game from the store.",
ProjectVStartupStatus.ContractMismatch => "Please update the game to keep playing.",
ProjectVStartupStatus.Offline => "No connection. Check your network and try again.",
_ => "Something went wrong. Please try again.",
};
var requestId = snapshot.Error?.RequestId;
blockingText.text = requestId == null ? message : $"{message}\nSupport code: {requestId}";
// Retrying cannot help until the player updates the game.
var canRetry = snapshot.Status != ProjectVStartupStatus.UpdateRequired &&
snapshot.Status != ProjectVStartupStatus.ContractMismatch;
retryButton.gameObject.SetActive(canRetry);
}
// Retry button on the blocking screen.
public async void OnRetryClicked()
{
if (lifetime == null)
{
return;
}
await startup.StartAsync(contentReady: true, lifetime.Token);
}
// May be called off the Unity main thread.
private void OnAuthenticationChanged(ProjectVAuthenticationSnapshot snapshot) => RenderAuthenticationAsync(snapshot).Forget();
private async UniTaskVoid RenderAuthenticationAsync(ProjectVAuthenticationSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
guestButton.interactable = !snapshot.IsBusy;
googleButton.interactable = !snapshot.IsBusy;
// Cancelled: the player closed a prompt or the page closed.
var error = snapshot.Error;
var showError = error != null && error.Category != ProjectVBackendErrorCategory.Cancelled;
loginErrorText.gameObject.SetActive(showError);
loginErrorText.text = showError ? SignInMessage(error!) : string.Empty;
}
private static string SignInMessage(ProjectVAuthenticationError error)
{
if (error.Category == ProjectVBackendErrorCategory.Network ||
error.Category == ProjectVBackendErrorCategory.Timeout)
{
return "No connection. Please try again.";
}
return "Sign-in failed. Please try again.";
}
public async void OnGuestClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var id = await guestIdentity.GetOrCreateAsync(token);
var result = await authentication.AuthenticateDeviceAsync(id, null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
public async void OnGoogleClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var result = await authentication.SignInWithGoogleAsync(null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
private async Task ContinueAfterSignInAsync(ProjectVAuthenticationCommandResult result, CancellationToken token)
{
if (this == null || !result.Snapshot.IsAuthenticated)
{
return; // the authentication listener shows the error
}
// The startup listener opens the Lobby on Ready.
await startup.StartAsync(contentReady: true, token);
}
private void OpenLobby()
{
if (lobbyRequested)
{
return;
}
lobbyRequested = true;
SceneManager.LoadScene(LobbyScene);
}
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Account;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using VContainer;
public sealed class AccountPopup : MonoBehaviour
{
private const string LoginScene = "Login";
[SerializeField] private Button logoutButton = null!;
[SerializeField] private TMP_Text errorText = null!;
private IProjectVAccountPresenter account = null!;
private IDisposable? subscription;
private CancellationTokenSource? lifetime;
private bool loginRequested;
[Inject]
public void Construct(IProjectVAccountPresenter presenter)
{
account = presenter ?? throw new ArgumentNullException(nameof(presenter));
}
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
var accountSub = await account.SubscribeAsync(OnAccountChanged);
if (token.IsCancellationRequested)
{
// The popup closed while subscribing.
accountSub.Dispose();
return;
}
subscription = accountSub;
}
private void OnDisable()
{
subscription?.Dispose();
subscription = null;
lifetime?.Cancel();
lifetime?.Dispose();
lifetime = null;
}
// May be called off the Unity main thread.
private void OnAccountChanged(ProjectVAccountSnapshot snapshot) => RenderAsync(snapshot).Forget();
private async UniTaskVoid RenderAsync(ProjectVAccountSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
// Sent once, right after the sign-out.
if (snapshot.RequiresLoginNavigation)
{
OpenLogin();
return;
}
logoutButton.interactable = !IsBusy(snapshot.Status);
var error = snapshot.Error;
var showError = error != null && error.Category != ProjectVBackendErrorCategory.Cancelled;
errorText.gameObject.SetActive(showError);
errorText.text = showError ? "Could not sign out. Please try again." : string.Empty;
}
public async void OnLogoutClicked()
{
if (lifetime == null)
{
return;
}
// The listener opens Login, or shows the error.
await account.LogoutAsync(lifetime.Token);
}
private static bool IsBusy(ProjectVAccountStatus status)
{
return status == ProjectVAccountStatus.RequestingDeletion ||
status == ProjectVAccountStatus.ConfirmingDeletion ||
status == ProjectVAccountStatus.ShuttingDown;
}
private void OpenLogin()
{
if (loginRequested)
{
return;
}
loginRequested = true;
SceneManager.LoadScene(LoginScene);
}
}
Trong Inspector, đặt On Click của mỗi nút sang public method tương ứng:
OnGuestClicked, OnGoogleClicked, OnRetryClicked và OnLogoutClicked.
Chạy thử
Xoá dữ liệu đã lưu của game, rồi vào Play mode từ scene Login:
- "Connecting…" hiện thoáng qua, rồi tới các nút đăng nhập;
- nút Google chỉ hiện ở nơi đã thiết lập đăng nhập Google;
- đăng nhập Guest tắt các nút, hiện màn đang tải và mở Lobby;
- dừng rồi vào lại Play mode thì mở th ẳng Lobby, không hiện các nút (phiên đăng nhập được giữ);
- Logout trong account popup mở Login với các nút đăng nhập;
- đăng nhập Guest sau khi đăng xuất quay về đúng tài khoản Guest cũ;
- khi tắt mạng, màn mất kết nối hiện kèm Retry, và Retry hoạt động khi có mạng lại;
- đóng scene trong lúc đang đăng nhập không gây lỗi nào trong Console.
Tiếp theo
- Trang Startup liệt kê mọi status và lý do quá trình khởi động dừng lại.
- Trang Authentication đề cập username và password, Apple, và mọi lỗi đăng nhập.
- Trang Account thêm chức năng xoá tài khoản vào cùng popup đó.
- Tiếp theo: Hướng dẫn: Lên cấp cho nhân vật.