Chuyển tới nội dung chính

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ứ:

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:

  • IsSuccess là 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.
  • IsSuccess là false: hiện Message; 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.

ShopSceneScope.cs
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);
}
}
ShopItem.cs
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();
}
}
ShopPage.cs
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.