Skip to main content

Campaign

The Campaign screen lists the missions, shows what is locked, the best-run stars and milestones and what a run can drop, and starts a mission before the gameplay scene loads. Missions, locks, stamina costs and stars are decided by the server.

You useIt gives you
IProjectVCampaignScreenThe screen contract: observe the missions, start one, release it
ProjectVCampaignViewWhat to draw: loading state, missions, total stars
ProjectVCampaignMissionOne mission: lock state, entry cost, stars, drop preview
ProjectVCampaignMissionStarsA mission's star objectives, milestones and best run
ProjectVResultThe outcome of a command

The run itself is played by the gameplay scene; see Gameplay data.

Quick startโ€‹

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation.Campaign;
using UnityEngine;
using VContainer;

public sealed class CampaignPage : MonoBehaviour
{
private IProjectVCampaignScreen campaign = null!;
private IDisposable? observation;
private bool starting;

[Inject]
public void Construct(IProjectVCampaignScreen screen) => campaign = screen;

private void OnEnable() => observation = campaign.Observe(Render);

private void OnDisable()
{
observation?.Dispose();
observation = null;
}

private void Render(ProjectVCampaignView view)
{
if (view.IsLoading)
{
ShowLoading(view.LoadError); // LoadError is null while the first load is running
return;
}
ShowMissions(view.Missions, view.TotalStars);
}

public async void OnStartClicked(ProjectVCampaignMission mission)
{
// A second tap would get the first tap's result; start and load only once.
if (starting)
{
return;
}
starting = true;
var result = await campaign.BeginMissionAsync(mission.MissionId.Value);
if (this == null)
{
return;
}
if (!result.IsSuccess)
{
starting = false;
ShowMessage(result.Message);
return;
}

var sceneName = FindScene(mission.GameplayKey.Value);
if (string.IsNullOrEmpty(sceneName))
{
// No scene for this mission: release the run so it costs nothing.
await campaign.CancelSceneLoadAsync();
starting = false;
return;
}
LoadScene(sceneName); // your scene loader; on failure call CancelSceneLoadAsync
}

private void ShowLoading(string? error) { }
private void ShowMissions(IReadOnlyList<ProjectVCampaignMission> missions, long totalStars) { }
private void ShowMessage(string message) { }
private string? FindScene(string gameplayKey) => null;
private void LoadScene(string sceneName) { }
}

That is the whole page: observe, draw, start a mission. After a run ends you do nothing; when the player comes back, IsCompleted, Stars and stamina are already up to date. Screen contracts shows the same flow from ProjectV-Client.

What to drawโ€‹

Render receives a ProjectVCampaignView:

MemberTypeUse it for
IsLoadingboolTrue until the missions arrive the first time. Show a spinner
LoadErrorstring?Set when that first load failed. Show it with a Retry button that closes and reopens the page
MissionsIReadOnlyList<ProjectVCampaignMission>The missions, with the player's progress on each
TotalStarslongStars earned across all missions
Find(missionId)ProjectVCampaignMission?Look up one mission, for example to refresh an open mission popup

For each ProjectVCampaignMission:

To showRead
Which scene to loadGameplayKey.Value, mapped to a scene in a table of your own
The entry costEntryStamina
The time limitDurationSeconds
CompletedIsCompleted
A run is openIsActive
LockedIsLocked: closed, a required mission not completed, or too few total stars. Disable Start
Not open yetIsClosed; a closed mission is always IsLocked
"Complete X first"RequiredMissionIds
What a run can dropDropPreview: Kind, ResourceId, Amount (the most a run can drop)
StarsStars (below)

For Stars, a ProjectVCampaignMissionStars:

To showRead
"2/3 stars"BestStars / MaxStars
The objectivesObjectives: ObjectiveId, Kind, Threshold; tick the ones in BestRunObjectiveIds
Milestone rewardsMilestones: StarsRequired, Rewards, IsClaimed
"Needs 12 stars"RequiresTotalStars, with IsLocked while TotalStars is below it

Objective Kind is one of complete_stage, duration_at_most_ms, enemies_defeated_min, health_percent_min and stages_cleared_min; Threshold is in the unit the kind names, and null for complete_stage. Use the kind to choose the objective text. A mission without stars has ProjectVCampaignMissionStars.None.

Match a mission to your art and text with MissionId.Value in a table of your own; the SDK does not provide names or icons.

Start a missionโ€‹

Task<ProjectVResult> BeginMissionAsync(string missionId)
ParameterTypeValue
missionIdstringmission.MissionId.Value of a mission in the current view that is not locked

The server starts a run and checks the entry stamina. Stamina is charged when the run ends, not now.

Returns a ProjectVResult:

IsSuccessMessageWhat happened
trueemptyThe server started the run. Load the gameplay scene
falsethe reason, ready to showThe run did not start, or could not be confirmed (see below)

When it fails โ€” show Message as it is. The common reasons:

SituationWhat the player sees
Not enough staminaThe server's message (code STAMINA_INSUFFICIENT)
The mission is lockedThe server's message (code MISSION_RUN_LOCKED)
Too few total starsThe server's message (code MISSION_RUN_LOCKED_STARS)
A null or blank missionId"The request was not confirmed. Please try again."
No connection or a timeoutA network message. Do not start again automatically

One start at a time, for all missions. A second tap while a start is running โ€” on the same mission or another one โ€” gets the first mission's result. Load the gameplay scene only once, for the mission that started; the result can also arrive after the page closed.

Release a run that cannot loadโ€‹

Task<ProjectVResult> CancelSceneLoadAsync()

Call it after a successful BeginMissionAsync when your table has no scene for the mission's GameplayKey, or when the scene fails to load. A released run costs no stamina.

Returns a ProjectVResult:

IsSuccessMessageWhat happened
trueemptyThe server released the run. The mission list redraws by itself
falsethe reason, ready to showThe run was not released; show Message and let the player try again

A second call while one is running gets the same result.

Stamina and starsโ€‹

  • Entry stamina is charged when the run ends: a win, a loss or a give-up. A restart in the gameplay scene, a released run, or a killed or crashed app costs nothing.
  • The server scores stars when the run ends. Campaign shows BestStars and claimed milestones; the gameplay scene shows the stars of the run it just played.
  • Show locks from IsLocked and Stars.IsLocked. Never work them out from local progress; the server refuses to start a locked mission anyway.
  • For QA, the staging backend can unlock every mission; Campaign then shows them all unlocked.

Common mistakesโ€‹

  • Loading the gameplay scene twice. A joined tap can resolve after the load already started. Keep a flag and load once.
  • Tapping a second mission while the first is starting. It returns the first mission's result; do not load the second mission's scene for it.
  • Leaving a started run open when the scene cannot load. Call CancelSceneLoadAsync.
  • Taking stamina off the display at Start. Stamina is charged when the run ends; the Header updates by itself.
  • Working out locks or stars from local data. Use the view.
  • Using the result after the page closed. Check this != null after await.
  • Not disposing the observation. Keep the IDisposable from Observe and dispose it in OnDisable or OnDestroy.

Presenterโ€‹

IProjectVCampaignPresenter is the lower-level interface behind the screen contract. Use it only if you need what the screen does not give:

MemberUse it for
SubscribeAsync(listener)Every ProjectVCampaignSnapshot: Status (Empty, Refreshing, Ready, Beginning, Failed), Recovery, Data
Current.ErrorA full ProjectVCampaignError with Code and RequestId, for example to choose your own text per code
RefreshAsync(token)Load the missions (returns at once when already loaded)
BeginAsync(missionId, token)Presenter form of BeginMissionAsync
CancelSceneLoadAdmissionAsync(token)Presenter form of CancelSceneLoadAsync

The commands return a ProjectVCampaignCommandResult.

SubscribeAsync listeners may run off the Unity main thread; switch to the main thread before touching Unity objects. The screen's Observe always renders on the main thread.