Hướng dẫn: dựng màn Shop
Trong hướng dẫn này bạn dựng một trang Shop từ đầu: trang liệt kê các gói (offer), cho người chơi chọn số lượng và mua. Bạn sẽ dùng bốn thứ:
IProjectVShopScreen: thứ trang giao tiếp;ProjectVShopView: thứ trang vẽ;ProjectVShopOfferView: một gói trong view đó;ProjectVResult: thứ một lượt mua trả về.
Trước khi bắt đầu: SDK đã được cài và đăng nhập Guest vào được Lobby
(Cài đặt và thiết lập), và scene Shop có một scope kế thừa
từ ProjectVAuthenticatedSceneScope
(Tích hợp scene).
1. Đăng ký trang
Trang là một MonoBehaviour trong scene Shop. Đăng ký nó trong scene scope để
nó nhận được screen contract:
public sealed class ShopSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<ShopPage>();
}
}
2. Nhận screen contract
Thêm một method [Inject]. SDK truyền vào IProjectVShopScreen duy nhất của
app; giữ nó trong một field.
using System;
using SilverTiger.ProjectV.Unity.Presentation.Shop;
using UnityEngine;
using VContainer;
public sealed class ShopPage : MonoBehaviour
{
private IProjectVShopScreen shop = null!;
[Inject]
public void Construct(IProjectVShopScreen screen)
{
shop = screen ?? throw new ArgumentNullException(nameof(screen));
}
}
3. Theo dõi khi trang đang mở
Observe(render) gọi render ngay lập tức với view hiện tại, và gọi lại mỗi
khi các gói thay đổi. Nó trả về một IDisposable: giữ lại, và dispose (giải
phóng) nó khi trang đóng để SDK ngừng gọi một trang đã không còn.
private IDisposable? observation;
private void OnEnable()
{
observation = shop.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
Bạn không tự tải gì cả: nếu các gói chưa được tải, việc theo dõi sẽ tải chúng.
4. Vẽ trạng thái đang tải và lỗi
Lần gọi đầu tiên có thể tới trước khi các gói về. Khi đó view.IsLoading là
true. Nếu lần tải đầu tiên đó thất bại, view.LoadError cho biết lý do.
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMPro.TMP_Text errorText = null!;
private void Render(ProjectVShopView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
errorText.gameObject.SetActive(view.LoadError != null);
errorText.text = view.LoadError ?? string.Empty;
if (view.IsLoading)
{
return;
}
DrawOffers(view);
}
Với nút thử lại (Retry), disable rồi enable lại trang: OnEnable theo dõi lại,
và việc đó tải lại.
5. Vẽ các gói
view.Offers là danh sách cần hiện, theo thứ tự của server. Mỗi phần tử là một
ProjectVShopOfferView. Ánh xạ
OfferId.Value sang art và tên của riêng bạn; SDK chỉ gửi ID và con số.
[SerializeField] private ShopItem itemPrefab = null!;
[SerializeField] private Transform list = null!;
private void DrawOffers(ProjectVShopView view)
{
foreach (Transform child in list)
{
Destroy(child.gameObject);
}
foreach (var offer in view.Offers)
{
if (offer.IsIap || (offer.LimitKind == ProjectVShopLimitKind.Forever && offer.PurchaseLimitReached))
{
continue; // store offers are not sold yet; one-time offers disappear once bought
}
var item = Instantiate(itemPrefab, list);
item.Show(
offerId: offer.OfferId.Value,
price: $"{offer.WalletPrice} {offer.WalletCurrencyId}",
limit: offer.LimitKind == ProjectVShopLimitKind.None ? "" : $"{offer.PurchaseCount}/{offer.LimitCount}",
soldOut: offer.PurchaseLimitReached,
onBuy: quantity => Buy(offer.OfferId.Value, quantity));
}
}
Render chạy lại sau mỗi lượt mua với giá trị PurchaseCount mới, nên danh
sách luôn đúng mà không cần thêm code nào.
6. Chọn số lượng
Bộ chọn số lượng của một gói đi từ QuantityMin đến số nhỏ nhất trong:
QuantityMax, phần còn lại của giới hạn, và số người chơi đủ tiền mua.
private static long MaxQuantity(ProjectVShopOfferView offer, long balance)
{
var max = offer.QuantityMax;
if (offer.LimitKind != ProjectVShopLimitKind.None)
{
max = Math.Min(max, offer.LimitCount - offer.PurchaseCount);
}
if (offer.WalletPrice > 0 && offer.WalletCurrencyId.Length > 0)
{
max = Math.Min(max, balance / offer.WalletPrice);
}
return max; // disable Buy when max < offer.QuantityMin
}
balance là số WalletCurrencyId người chơi đang có, đọc từ
Header (ProjectVHeaderSnapshot.Currencies, khớp theo
CurrencyId.Value). Các giới hạn này chỉ định hình bộ chọn; dù sao server cũng
kiểm tra mọi lượt mua.
7. Mua
Gọi PurchaseAsync(offerId, quantity). Nó trả về một ProjectVResult:
IsSuccesslà true: xong. Không làm gì thêm.Renderđược gọi với số lượt mua mới, và Header hiện số dư mới.IsSuccesslà false: hiệnMessage; nó được viết sẵn cho người chơi.
private async void Buy(string offerId, int quantity)
{
var result = await shop.PurchaseAsync(offerId, quantity);
// The page may have closed while the purchase ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (!result.IsSuccess)
{
ShowPopup(result.Message);
}
}
Bạn không cần tắt nút Buy: lần bấm thứ hai trong lúc lượt mua đang chạy nhận cùng kết quả thay vì mua hai lần.
Toàn bộ code
Ba file của tutorial này, đầy đủ. ShopItem là một prefab dòng đơn giản:
một nhãn giá, một nhãn giới hạn, một nhãn số lượng với nút − và +, và một nút Mua.
using SilverTiger.ProjectV.Unity.Presentation;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;
public sealed class ShopSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<ShopPage>();
}
protected override void DisableScenePresentation()
{
var page = FindFirstObjectByType<ShopPage>(FindObjectsInactive.Include);
if (page != null)
{
page.enabled = false;
}
}
protected override void NavigateToLogin(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
}
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public sealed class ShopItem : MonoBehaviour
{
[SerializeField] private TMP_Text priceText = null!;
[SerializeField] private TMP_Text limitText = null!;
[SerializeField] private TMP_Text quantityText = null!;
[SerializeField] private Button minusButton = null!;
[SerializeField] private Button plusButton = null!;
[SerializeField] private Button buyButton = null!;
private int quantity = 1;
public void Show(string offerId, string price, string limit, bool soldOut, Action<int> onBuy)
{
name = offerId;
priceText.text = price;
limitText.text = limit;
buyButton.interactable = !soldOut;
quantityText.text = quantity.ToString();
minusButton.onClick.AddListener(() => SetQuantity(quantity - 1));
plusButton.onClick.AddListener(() => SetQuantity(quantity + 1));
buyButton.onClick.AddListener(() => onBuy(quantity));
}
private void SetQuantity(int value)
{
quantity = Math.Max(1, value);
quantityText.text = quantity.ToString();
}
}
using System;
using SilverTiger.ProjectV.Unity.Presentation.Shop;
using UnityEngine;
using VContainer;
public sealed class ShopPage : MonoBehaviour
{
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMPro.TMP_Text errorText = null!;
[SerializeField] private ShopItem itemPrefab = null!;
[SerializeField] private Transform list = null!;
private IProjectVShopScreen shop = null!;
private IDisposable? observation;
[Inject]
public void Construct(IProjectVShopScreen screen)
{
shop = screen ?? throw new ArgumentNullException(nameof(screen));
}
private void OnEnable()
{
observation = shop.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
private void Render(ProjectVShopView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
errorText.gameObject.SetActive(view.LoadError != null);
errorText.text = view.LoadError ?? string.Empty;
if (view.IsLoading)
{
return;
}
DrawOffers(view);
}
private void DrawOffers(ProjectVShopView view)
{
foreach (Transform child in list)
{
Destroy(child.gameObject);
}
foreach (var offer in view.Offers)
{
if (offer.IsIap || (offer.LimitKind == ProjectVShopLimitKind.Forever && offer.PurchaseLimitReached))
{
continue; // store offers are not sold yet; one-time offers disappear once bought
}
var item = Instantiate(itemPrefab, list);
item.Show(
offerId: offer.OfferId.Value,
price: $"{offer.WalletPrice} {offer.WalletCurrencyId}",
limit: offer.LimitKind == ProjectVShopLimitKind.None ? "" : $"{offer.PurchaseCount}/{offer.LimitCount}",
soldOut: offer.PurchaseLimitReached,
onBuy: quantity => Buy(offer.OfferId.Value, quantity));
}
}
private static long MaxQuantity(ProjectVShopOfferView offer, long balance)
{
var max = offer.QuantityMax;
if (offer.LimitKind != ProjectVShopLimitKind.None)
{
max = Math.Min(max, offer.LimitCount - offer.PurchaseCount);
}
if (offer.WalletPrice > 0 && offer.WalletCurrencyId.Length > 0)
{
max = Math.Min(max, balance / offer.WalletPrice);
}
return max; // disable Buy when max < offer.QuantityMin
}
private async void Buy(string offerId, int quantity)
{
var result = await shop.PurchaseAsync(offerId, quantity);
// The page may have closed while the purchase ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (!result.IsSuccess)
{
ShowPopup(result.Message);
}
}
private void ShowPopup(string message)
{
errorText.gameObject.SetActive(true);
errorText.text = message;
}
}
8. Chạy thử
Vào Play mode từ scene Login, đăng nhập bằng Guest và mở Shop:
- các gói hiện ra (lần đầu có spinner hiện thoáng qua);
- mua một gói bạn đủ tiền thì thành công, số dư trên Header giảm và số lượt mua tăng;
- mua nhiều hơn số bạn đủ tiền thì hiện thông báo của server;
- bấm hai lần thật nhanh chỉ mua một lần;
- đóng trang trong lúc đang mua không gây lỗi nào trong Console.
Tiếp theo
- Trang Shop liệt kê mọi thông báo lỗi và các lỗi thường gặp.
- API reference có mọi member của các type này.
- Các màn khác hoạt động theo cùng cách: Screen contract.