Tutorial: build the Shop screen
In this tutorial you build a Shop page from nothing: it lists the offers, lets the player pick a quantity and buys. You will use four things:
IProjectVShopScreen: what the page talks to;ProjectVShopView: what the page draws;ProjectVShopOfferView: one offer in that view;ProjectVResult: what a purchase returns.
Before you start: the SDK is installed and Guest login reaches the Lobby
(Install and set up), and the Shop scene has a scope
derived from ProjectVAuthenticatedSceneScope
(Scene integration).
1. Register the pageโ
The page is a MonoBehaviour in the Shop scene. Register it in the scene
scope so it can receive the screen contract:
public sealed class ShopSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<ShopPage>();
}
}
2. Receive the screen contractโ
Add an [Inject] method. The SDK passes the one IProjectVShopScreen of the
app; keep it in a 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. Observe while the page is openโ
Observe(render) calls render right away with the current view, and again
each time the offers change. It returns an IDisposable: keep it, and
dispose it when the page closes so the SDK stops calling a page that is gone.
private IDisposable? observation;
private void OnEnable()
{
observation = shop.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
You do not load anything yourself: if the offers are not loaded yet, observing loads them.
4. Draw loading and errorsโ
The first call may come before the offers arrive. Then view.IsLoading is
true. If that first load failed, view.LoadError says why.
[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);
}
For a Retry button, disable and re-enable the page: OnEnable observes
again, which loads again.
5. Draw the offersโ
view.Offers is the list to show, in server order. Each item is a
ProjectVShopOfferView. Map
OfferId.Value to your own art and names; the SDK only sends IDs and numbers.
[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 runs again after every purchase with new PurchaseCount values, so
the list stays correct without any extra code.
6. Pick a quantityโ
The quantity picker for one offer goes from QuantityMin to the smallest of:
QuantityMax, what is left of the limit, and what the player can afford.
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 is the player's amount of WalletCurrencyId, read from the
Header (ProjectVHeaderSnapshot.Currencies, matching
CurrencyId.Value). These limits only shape the picker; the server checks
every purchase anyway.
7. Buyโ
Call PurchaseAsync(offerId, quantity). It returns a ProjectVResult:
IsSuccesstrue: done. Do nothing else.Renderis called with the new purchase count, and the Header shows the new balance.IsSuccessfalse: showMessage; it is written for the player.
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);
}
}
You do not need to disable the Buy button: a second tap while a purchase is running gets the same result instead of buying twice.
Full codeโ
The three files of this tutorial, complete. ShopItem is a simple row prefab:
a price label, a limit label, a quantity label with โ and + buttons, and a Buy
button.
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. Try itโ
Enter Play mode from the Login scene, sign in as Guest and open the Shop:
- the offers appear (a spinner shows briefly the first time);
- buying an offer you can afford succeeds, the Header balance drops and the purchase count goes up;
- buying more than you can afford shows the server's message;
- a fast double tap buys only once;
- closing the page during a purchase gives no error in the Console.
What's nextโ
- The Shop page lists every failure message and the common mistakes.
- The API reference has every member of these types.
- The other screens work the same way: Screen contracts.