Skip to main content

Header

The top bar on every signed-in scene shows the player's currency balances and stamina. It updates by itself whenever the server confirms a change (a purchase, a mission, an upgrade, a daily grant); the header has no commands.

You useIt gives you
IProjectVHeaderPresenterObserve the header
ProjectVHeaderSnapshotWhat to draw: balances and stamina
ProjectVHeaderCurrencyValueOne balance and the slot it goes in
ProjectVStaminaRecoveryLive stamina and its countdown
ProjectVStaminaReadingStamina at one moment, with the waits until the next point and until full
ProjectVHeaderCurrencyCatalogAssetWhich currency each header slot shows

The header has no screen contract; the top bar subscribes to the presenter.

Quick start​

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Header;
using TMPro;
using UnityEngine;
using VContainer;

public sealed class TopBar : MonoBehaviour
{
[SerializeField] private TMP_Text coreDustText = null!;
[SerializeField] private TMP_Text staminaText = null!;
[SerializeField] private TMP_Text countdownText = null!;

private IProjectVHeaderPresenter header = null!;
private IDisposable? subscription;
private CancellationTokenSource? lifetime;
private ProjectVStaminaRecovery? recovery;

[Inject]
public void Construct(IProjectVHeaderPresenter presenter) => header = presenter;

private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
var headerSub = await header.SubscribeAsync(OnHeaderChanged);
if (token.IsCancellationRequested)
{
// The top bar closed while subscribing.
headerSub.Dispose();
return;
}
subscription = headerSub;
}

private void OnDisable()
{
subscription?.Dispose();
subscription = null;
lifetime?.Cancel();
lifetime?.Dispose();
lifetime = null;
recovery = null; // so a late update cannot bring back an old countdown
}

// May be called off the Unity main thread.
private void OnHeaderChanged(ProjectVHeaderSnapshot snapshot) => RenderAsync(snapshot).Forget();

private async UniTaskVoid RenderAsync(ProjectVHeaderSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null || !isActiveAndEnabled)
{
return;
}

foreach (var currency in snapshot.Currencies)
{
if (currency.ViewKey.Equals(ProjectVHeaderViewKeys.CoreDust))
{
coreDustText.text = currency.Amount.ToString();
}
}
recovery = snapshot.StaminaRecovery;
}

private void Update()
{
if (recovery == null)
{
return;
}

var reading = recovery.Read(DateTimeOffset.UtcNow);
staminaText.text = $"{reading.Stamina}/{reading.Maximum}";
countdownText.gameObject.SetActive(!reading.IsFull);
if (reading.UntilNextPoint is TimeSpan wait)
{
countdownText.text = wait.ToString(@"mm\:ss");
}
}
}

That is the whole top bar: subscribe, store, read stamina every frame. Never add a purchase or a mission reward to a balance yourself; the next snapshot carries the confirmed value.

Main thread

SubscribeAsync listeners may run off the Unity main thread. Switch to the main thread before touching Unity objects, as RenderAsync does.

What to draw​

The listener receives a ProjectVHeaderSnapshot:

MemberTypeUse it for
CurrenciesIReadOnlyList<ProjectVHeaderCurrencyValue>One balance per header slot, in catalog order. Empty until HasEconomy
StaminaRecoveryProjectVStaminaRecovery?Live stamina and the countdown; null until stamina is known
Staminalong?The raw value the server last reported. Not live: use StaminaRecovery.Read(DateTimeOffset.UtcNow) to show stamina
HasEconomyboolThe balances are loaded
HasStaminaboolStamina is loaded
IsReadyboolBoth are loaded; the whole header can be shown
RevisionlongGrows with every update. Not needed in UI

header.Current holds the latest snapshot; check IsReady before showing it.

For each ProjectVHeaderCurrencyValue:

MemberUse it for
ViewKeyThe slot to show it in. Compare with Equals against ProjectVHeaderViewKeys
CurrencyIdThe wallet currency, for example core_dust
AmountThe balance, never negative; 0 when the player has none

Currencies​

Which currency goes in which slot is set in an asset, not in code. A ProjectVHeaderCurrencyCatalogAsset (create it from Assets › Create › ProjectV › Backend V3 › Header Currency Catalog) holds one row per slot: a view key and a currency ID, for example header.core_dust → core_dust. Assign it to the root's ProjectVPresentationLifetimeScope. In ProjectV it is Assets/_Game/BackendV3/Settings/ProjectVHeaderCurrencyCatalog.asset.

The built-in slots are in ProjectVHeaderViewKeys:

KeySlot
Creditheader.credit
Goldheader.gold
CoreDustheader.core_dust
Diamondheader.diamond

Each view key may appear only once in the catalog; an empty, blank or duplicate row fails when the scope is built. Without an asset, Currencies stays empty.

Stamina countdown​

ProjectVStaminaReading Read(DateTimeOffset nowUtc)
ParameterTypeValue
nowUtcDateTimeOffsetDateTimeOffset.UtcNow

Stamina keeps recovering between server updates. Read works out the value at nowUtc locally, without asking the server, so call it every frame or every second.

Returns a ProjectVStaminaReading:

MemberTypeUse it for
StaminalongThe stamina to show now, including points recovered since the last update
MaximumlongThe maximum
UntilNextPointTimeSpan?The countdown to the next point. null when full, above the maximum, or not recovering
IsFullboolNo point is recovering; hide the countdown
UntilFullTimeSpan?How long until stamina is full; null when full or not recovering
FullAtUtcDateTimeOffset?When stamina is full, in UTC; convert to local time to show it

Purchased stamina can go above Maximum; then nothing recovers and UntilNextPoint is null.

UntilFull and FullAtUtc help the player choose when to come back without wasting recovery:

if (reading.UntilFull is TimeSpan untilFull && reading.FullAtUtc is DateTimeOffset fullAt)
{
// For example "Full in 3h 25m (21:30)".
fullText.text = $"Full in {(int)untilFull.TotalHours}h {untilFull.Minutes:00}m ({fullAt.ToLocalTime():HH:mm})";
}

A purchase, a mission charge or a higher maximum arrives as a new snapshot with a new StaminaRecovery; store it and keep calling Read.

Rules​

  • Subscribe once when the top bar is shown and dispose the subscription when it is hidden.
  • Show stamina from StaminaRecovery.Read, not from Stamina.
  • Never add or subtract balances or stamina yourself.

Common mistakes​

  • Showing Stamina as the live value. It is the last server value and does not count recovery.
  • Counting recovery yourself. Read does it from the same server clock as the countdown.
  • Keeping the old StaminaRecovery after the bar is hidden. Clear it in OnDisable, as the quick start does.
  • Matching slots by currency name in code. Use the catalog asset and ProjectVHeaderViewKeys.
  • Touching Unity objects in the listener without switching threads.

UI binding helper​

header.SubscribeUiAsync(ProjectVUiBindingId.Header, apply, token) works like SubscribeAsync and also marks in the SDK log when apply returned (see UI subscriptions). It does not switch threads; switch to the main thread yourself.