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.
| Screen | Namespace (SilverTiger.ProjectV.Unity.Presentation.*) | View | Commands |
|---|---|---|---|
IProjectVLobbyScreen | .Lobby | ProjectVLobbyView | none |
IProjectVShopScreen | .Shop | ProjectVShopView | PurchaseAsync(offerId, quantity) |
IProjectVCampaignScreen | .Campaign | ProjectVCampaignView | BeginMissionAsync(missionId), CancelSceneLoadAsync() |
IProjectVCharacterScreen | .Character | ProjectVCharacterView | SelectCharacterAsync, LevelUpAsync, UpgradeSkillAsync, UpgradeCastSkillAsync, ResetSkillsAsync, ResetLevelAsync |
IProjectVAllianceScreen | .Alliance | ProjectVAllianceView | SelectAllyAsync, 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
Observerenders at once, then only when the data changes. It never calls you while a request is in flight, andrenderalways 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.
IsLoadingis true only until the first data arrives;LoadErrorexplains 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.IsSuccesstells you whether the server confirmed the action.Messageis text for the player, empty on success; show it as it is. The new data arrives throughObserve, 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.