Skip to main content

Screen contracts

Lobby, Character, Alliance, Campaign and Shop each have a screen contract: a small interface the page observes and calls. Use it rather than the screen's presenter. The page renders a view and sends commands; loading, retries and double taps are handled for you.

ScreenNamespace (SilverTiger.ProjectV.Unity.Presentation.*)ViewCommands
IProjectVLobbyScreen.LobbyProjectVLobbyViewnone
IProjectVShopScreen.ShopProjectVShopViewPurchaseAsync(offerId, quantity)
IProjectVCampaignScreen.CampaignProjectVCampaignViewBeginMissionAsync(missionId), CancelSceneLoadAsync()
IProjectVCharacterScreen.CharacterProjectVCharacterViewSelectCharacterAsync, LevelUpAsync, UpgradeSkillAsync, UpgradeCastSkillAsync, ResetSkillsAsync, ResetLevelAsync
IProjectVAllianceScreen.AllianceProjectVAllianceViewSelectAllyAsync, LevelUpAsync, UpgradeWeaponSkillAsync, ResetLevelAsync

Startup, Authentication, Account, Header and Gameplay data have no screen contract; use their presenters. On Character, equipment, backpack, skins and confirmation dialogs are not on the contract yet; use IProjectVCharacterPresenter for those.

The shape​

XxxView Current { get; } // the last confirmed data, never null
IDisposable Observe(Action<XxxView> render); // render now, then again when the data changes
Task<ProjectVResult> SomeCommandAsync(...); // one request per tap
  • Observe renders at once, then only when the data changes. It never calls you while a request is in flight, and render always runs on the Unity main thread. It also loads the data if needed, so do not call a refresh. Dispose the result when the page closes.
  • A view always shows the last confirmed data. IsLoading is true only until the first data arrives; LoadError explains a failed first load, for a retry prompt. Previews (EXP, level reset) also use the confirmed data, so they do not drop to zero during a request.
  • Commands return ProjectVResult. IsSuccess tells you whether the server confirmed the action. Message is text for the player, empty on success; show it as it is. The new data arrives through Observe, not through the result.
  • A repeated tap joins the running command and gets the same result. You do not need to disable the button or debounce it.
  • Commands take no CancellationToken. Closing the page does not undo the action on the server. Check that the page still exists before using the result.
  • No Sync or Refresh button is needed on Character and Alliance. If a result could not be confirmed, those screens resynchronise and redraw by themselves. On Shop, show the message and let the player decide whether to try again.

Example: Shop​

From ProjectV-Client's ShopUIManager:

private IProjectVShopScreen shop = null!;
private IDisposable? subscription;

[Inject]
public void Construct(IProjectVShopScreen shop)
{
this.shop = shop ?? throw new ArgumentNullException(nameof(shop));
}

private void Start()
{
// Renders the current catalog now, then again only when the offers change.
subscription = shop.Observe(ApplyShopView);
}

private void OnDestroy()
{
subscription?.Dispose();
subscription = null;
}

private void ApplyShopView(ProjectVShopView view)
{
if (this == null)
{
return;
}
if (view.IsLoading)
{
ShowLoading(view.LoadError);
return;
}
RenderOffers(view.Offers);
}

private async Task BuyAsync(string offerId, int quantity)
{
// The listing and balances update through ApplyShopView once the server confirms.
var result = await shop.PurchaseAsync(offerId, quantity);
if (this != null && !result.IsSuccess)
{
ShowMessage(result.Message);
}
}

private void ShowLoading(string? loadError) { }
private void RenderOffers(IReadOnlyList<ProjectVShopOfferView> offers) { }
private void ShowMessage(string message) { }

Example: begin a mission​

Based on ProjectV-Client's UICampaignManager, with a guard against taps on other missions:

private async Task BeginAndLoadAsync(ProjectVCampaignMission mission, string sceneName, CancellationToken token)
{
// Set the guard before awaiting: a tap on another mission while one starts
// gets the first mission's result, so ignore taps until this one is done.
if (starting)
{
return;
}
starting = true;

var result = await campaign.BeginMissionAsync(mission.MissionId.Value);
if (!result.IsSuccess)
{
starting = false;
ShowMessage(result.Message);
return;
}

if (token.IsCancellationRequested || this == null)
{
return;
}

if (string.IsNullOrEmpty(sceneName))
{
await campaign.CancelSceneLoadAsync();
starting = false;
return;
}

// starting stays true: the scene loads once for this run.
SceneLoader.Instance.LoadScene(sceneName); // game-owned loader
}

Hosts without VContainer, and tests​

ProjectVPresentationLifetimeScope registers one instance of each screen for the session; inject the interface. Tools and tests can build a screen over a presenter with ProjectVScreens.Lobby(presenter), .Shop, .Campaign, .Character or .Alliance. The optional dispatch argument moves rendering to the main thread; without it, render runs inline. ProjectVScreens.CharacterView(snapshot) builds a Character view from one snapshot, without a presenter.

Next: Presenter reference.