Tutorial: play a mission end to end
In this tutorial you follow one mission through three scenes: the Campaign page starts it, the gameplay scene plays it and sends the result, and the Lobby shows what changed. You will use five things:
IProjectVCampaignScreen: what the Campaign page talks to;ProjectVCampaignMission: one mission in the list;IProjectVGameplayDataConsumer: what the gameplay scene implements;IProjectVGameplayDataPolicy: the run, with the helpers that build the result;ProjectVGameplayDataResult: what ending the run returns.
Before you start: finish the previous tutorial,
Level up a character. You need a
Campaign scene with a scope derived from ProjectVAuthenticatedSceneScope
(Scene integration),
and a gameplay scene in the build settings.
1. Register the Campaign pageโ
The Campaign page is a MonoBehaviour in the Campaign scene. Register it in
the scene scope, as for the Shop:
public sealed class CampaignSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<CampaignPage>();
}
}
2. List the missionsโ
Inject IProjectVCampaignScreen, observe it in OnEnable and dispose the
observation in OnDisable. Each mission shows its stars, its entry stamina
and why it is locked. Read the lock from the view; never work it out from
local progress.
private static string LockReason(ProjectVCampaignMission mission)
{
if (mission.IsClosed)
{
return "Not open yet";
}
if (mission.Stars.IsLocked)
{
return $"Needs {mission.Stars.RequiresTotalStars} stars";
}
return mission.IsLocked ? "Complete the previous mission" : string.Empty;
}
Show stars as Stars.BestStars / Stars.MaxStars; MaxStars is 0 for a
mission without stars. MissionId.Value is only an ID: map it to your own
name and art.
3. Start a missionโ
Call BeginMissionAsync(missionId). On success, map GameplayKey.Value to a
scene with your own table and load it. The entry stamina is only checked now;
it is charged when the run ends.
if (starting)
{
return;
}
starting = true;
var result = await campaign.BeginMissionAsync(mission.MissionId.Value);
if (this == null)
{
return;
}
Keep the starting flag. A second tap while a start is running, even on
another mission, gets the first mission's result; without the flag it could
load a scene twice, or the wrong scene. If your table has no scene for the
mission, call CancelSceneLoadAsync(): the run is released and costs no
stamina. Do the same if your own scene loader fails.
4. Bind the run in the gameplay sceneโ
The gameplay scene needs no scope. Add one MissionBinding component that
implements IProjectVGameplayDataConsumer. When the scene loads, the SDK finds
it and calls BindGameplayData on the Unity main thread. Keep exactly one in
the scene, inactive objects included; with none or two, nothing is bound.
public void BindGameplayData(IProjectVGameplayDataPresenter presenter, ProjectVGameplayStartingDataView startingData)
{
// If Bind throws, the SDK cancels the run; it costs no stamina.
run = presenter as IProjectVGameplayDataPolicy
?? throw new InvalidOperationException("The run has no gameplay data policy.");
data = startingData;
SpawnCharacter(startingData);
foreach (var drop in startingData.Drops)
{
Instantiate(enemyPrefab, enemyRoot).Activate(this, drop.SlotId);
}
}
Spawn ActiveCharacterId, not a locally selected character, and apply
Stats instead of your asset values. UnbindGameplayData is called when the
run ends or the scene unloads; clear your references there, because every
call on the presenter fails from then on.
A real scene also checks its skill assets before play starts; see Check the skill assets.
5. Track reward enemiesโ
Each entry in Drops is a reward enemy with a SlotId. Register the enemy
when it becomes active, record its death, and unregister it when it is
deactivated. The lease (spawnCount here) must change on every activation,
so a pooled enemy is never mistaken for its earlier life.
public void OnRewardEnemyActivated(RewardEnemy enemy, string slotId, long spawnCount)
{
if (run == null)
{
return;
}
var result = run.RegisterDropOwner(enemy, slotId, spawnCount, out var registration);
if (result.IsSuccess)
{
drops[enemy] = registration;
}
}
RecordDropDefeated(registration) records the death, and
UnregisterDropOwner(registration) does not undo it. You never decide which
drops the player earned; the SDK and the server do that from what you report.
6. Record skill usesโ
Each time the player uses a skill, report the SkillId of the matching slot
in CastSkills:
foreach (var cast in data.CastSkills)
{
if (cast.Slot == slot)
{
run.RecordSkillActivated(cast.SkillId, slot);
}
}
7. End the runโ
When the player wins or loses, call ObserveOutcome(outcome, kills) with a
kill count from 0 to 1024. It locks the run: skill uses and Restart are
refused from then on. Then call CompleteObservedAsync(health, armor, experience, token) and show only what it returns.
var result = await current.CompleteObservedAsync(health, armor, experience, CancellationToken.None);
if (this == null)
{
return;
}
if (!result.IsSuccess)
{
ShowResult(result.SafeMessage, canRetry: true, canLeave: false);
return;
}
ShowResult(Describe(result), canRetry: false, canLeave: true);
On success, show Rewards, mention MailedRewards (they did not fit and went
to the mailbox) and show Stars, which is null for a loss. On failure, keep
the result screen open with Retry, and Retry makes the same call. The first
call fixed the result; a retry resends it and ignores new values.
8. Give up or restartโ
A give-up is an outcome: report it as Abandoned and end the run like a
loss. It costs the entry stamina.
A restart is not an outcome. Pause > Restart calls CancelRunAsync, which
ends the run without a result and without spending stamina, then goes back
to Campaign. It is refused once ObserveOutcome was called.
var result = await current.CancelRunAsync(CancellationToken.None);
if (this != null && result.IsSuccess)
{
SceneManager.LoadScene(campaignScene);
}
9. Back in the Lobbyโ
Load the Campaign or Lobby scene. You do not reload anything. Campaign already shows the mission as completed with its new best stars, the Header shows the new balances and the stamina after the entry cost, and the Lobby shows the player's level as the server now has it.
Full codeโ
The five files of this tutorial, complete. MissionItem is a row prefab with
a title, stars, stamina and lock labels and a Start button. RewardEnemy is
an enemy prefab with a collider; clicking it defeats it. The win, lose, give-up,
restart and skill buttons of the gameplay scene call the public methods of
MissionBinding; in your game, your rules call them.
using SilverTiger.ProjectV.Unity.Presentation;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;
public sealed class CampaignSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<CampaignPage>();
}
protected override void DisableScenePresentation()
{
var page = FindFirstObjectByType<CampaignPage>(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 MissionItem : MonoBehaviour
{
[SerializeField] private TMP_Text titleText = null!;
[SerializeField] private TMP_Text starsText = null!;
[SerializeField] private TMP_Text staminaText = null!;
[SerializeField] private TMP_Text lockText = null!;
[SerializeField] private Button startButton = null!;
public void Show(string missionId, string stars, long entryStamina, string lockReason, Action onStart)
{
name = missionId;
titleText.text = missionId; // map the ID to your own mission name
starsText.text = stars;
staminaText.text = entryStamina.ToString();
lockText.gameObject.SetActive(lockReason.Length > 0);
lockText.text = lockReason;
startButton.interactable = lockReason.Length == 0;
startButton.onClick.AddListener(() => onStart());
}
}
using System;
using SilverTiger.ProjectV.Unity.Presentation.Campaign;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
public sealed class CampaignPage : MonoBehaviour
{
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMP_Text messageText = null!;
[SerializeField] private TMP_Text totalStarsText = null!;
[SerializeField] private MissionItem itemPrefab = null!;
[SerializeField] private Transform list = null!;
// Your scene table: gameplayKeys[i] plays in sceneNames[i].
[SerializeField] private string[] gameplayKeys = Array.Empty<string>();
[SerializeField] private string[] sceneNames = Array.Empty<string>();
private IProjectVCampaignScreen campaign = null!;
private IDisposable? observation;
private bool starting;
[Inject]
public void Construct(IProjectVCampaignScreen screen)
{
campaign = screen ?? throw new ArgumentNullException(nameof(screen));
}
private void OnEnable()
{
observation = campaign.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
private void Render(ProjectVCampaignView view)
{
if (view.IsLoading)
{
loadingPanel.SetActive(view.LoadError == null);
if (view.LoadError != null)
{
ShowMessage(view.LoadError);
}
return;
}
loadingPanel.SetActive(false);
totalStarsText.text = view.TotalStars.ToString();
foreach (Transform child in list)
{
Destroy(child.gameObject);
}
foreach (var mission in view.Missions)
{
var stars = mission.Stars.MaxStars > 0 ? $"{mission.Stars.BestStars}/{mission.Stars.MaxStars}" : string.Empty;
var item = Instantiate(itemPrefab, list);
item.Show(
missionId: mission.MissionId.Value,
stars: stars,
entryStamina: mission.EntryStamina,
lockReason: LockReason(mission),
onStart: () => StartMission(mission));
}
}
private static string LockReason(ProjectVCampaignMission mission)
{
if (mission.IsClosed)
{
return "Not open yet";
}
if (mission.Stars.IsLocked)
{
return $"Needs {mission.Stars.RequiresTotalStars} stars";
}
return mission.IsLocked ? "Complete the previous mission" : string.Empty;
}
private async void StartMission(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 (sceneName == null)
{
// No scene for this mission: release the run so it costs nothing.
var cancel = await campaign.CancelSceneLoadAsync();
if (this == null)
{
return;
}
starting = false;
ShowMessage(cancel.IsSuccess ? "This mission is not available yet." : cancel.Message);
return;
}
SceneManager.LoadScene(sceneName);
}
private string? FindScene(string gameplayKey)
{
var index = Array.IndexOf(gameplayKeys, gameplayKey);
return index >= 0 && index < sceneNames.Length ? sceneNames[index] : null;
}
private void ShowMessage(string message)
{
messageText.gameObject.SetActive(true);
messageText.text = message;
}
}
using UnityEngine;
public sealed class RewardEnemy : MonoBehaviour
{
private MissionBinding? binding;
private long spawnCount;
public void Activate(MissionBinding owner, string slotId)
{
binding = owner;
spawnCount++; // a new lease on every activation
gameObject.SetActive(true);
owner.OnRewardEnemyActivated(this, slotId, spawnCount);
}
// Your combat code calls this; here a click on the enemy's collider.
public void Defeat()
{
if (binding != null)
{
binding.OnRewardEnemyDefeated(this);
}
gameObject.SetActive(false);
}
private void OnMouseDown()
{
Defeat();
}
private void OnDisable()
{
if (binding != null)
{
binding.OnRewardEnemyDeactivated(this);
}
}
}
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using SilverTiger.ProjectV.Unity.Presentation.Gameplay;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public sealed class MissionBinding : MonoBehaviour, IProjectVGameplayDataConsumer
{
[SerializeField] private GameObject characterPrefab = null!;
[SerializeField] private RewardEnemy enemyPrefab = null!;
[SerializeField] private Transform enemyRoot = null!;
[SerializeField] private TMP_Text statsText = null!;
[SerializeField] private GameObject resultPanel = null!;
[SerializeField] private TMP_Text resultText = null!;
[SerializeField] private Button retryButton = null!;
[SerializeField] private Button leaveButton = null!;
[SerializeField] private string campaignScene = "Campaign";
[SerializeField] private long experiencePerKill = 10;
private readonly Dictionary<RewardEnemy, ProjectVGameplayDropRegistration> drops = new();
private IProjectVGameplayDataPolicy? run;
private ProjectVGameplayStartingDataView? data;
private float health;
private int armor;
private long kills;
private long experience;
private bool ended;
private void Awake()
{
resultPanel.SetActive(false);
retryButton.onClick.AddListener(SendResult);
leaveButton.onClick.AddListener(() => SceneManager.LoadScene(campaignScene));
}
public void BindGameplayData(IProjectVGameplayDataPresenter presenter, ProjectVGameplayStartingDataView startingData)
{
// If Bind throws, the SDK cancels the run; it costs no stamina.
run = presenter as IProjectVGameplayDataPolicy
?? throw new InvalidOperationException("The run has no gameplay data policy.");
data = startingData;
SpawnCharacter(startingData);
foreach (var drop in startingData.Drops)
{
Instantiate(enemyPrefab, enemyRoot).Activate(this, drop.SlotId);
}
}
public void UnbindGameplayData(IProjectVGameplayDataPresenter presenter)
{
if (!ReferenceEquals(run, presenter))
{
return;
}
run = null;
data = null;
drops.Clear();
}
private void SpawnCharacter(ProjectVGameplayStartingDataView startingData)
{
var character = Instantiate(characterPrefab);
character.name = startingData.ActiveCharacterId; // pick your prefab from this ID
health = startingData.MaximumHealth;
armor = startingData.MaximumArmor;
var stats = startingData.Stats;
statsText.text = $"HP {stats.MaxHealth} ATK {stats.AttackDamage} ARM {stats.Armor} SPD {stats.MoveSpeed}";
}
public void OnRewardEnemyActivated(RewardEnemy enemy, string slotId, long spawnCount)
{
if (run == null)
{
return;
}
var result = run.RegisterDropOwner(enemy, slotId, spawnCount, out var registration);
if (result.IsSuccess)
{
drops[enemy] = registration;
}
else
{
Debug.LogWarning(result.SafeMessage);
}
}
public void OnRewardEnemyDefeated(RewardEnemy enemy)
{
if (run == null || ended)
{
return;
}
kills++;
experience += experiencePerKill;
if (drops.TryGetValue(enemy, out var registration))
{
run.RecordDropDefeated(registration);
}
}
public void OnRewardEnemyDeactivated(RewardEnemy enemy)
{
if (run != null && drops.Remove(enemy, out var registration))
{
run.UnregisterDropOwner(registration); // a recorded death still counts
}
}
public void UseCharacterSkill()
{
UseSkill(ProjectVGameplayDataSkillSlot.Character);
}
public void UseWeaponSkill()
{
UseSkill(ProjectVGameplayDataSkillSlot.Weapon);
}
private void UseSkill(ProjectVGameplayDataSkillSlot slot)
{
if (run == null || data == null || ended)
{
return;
}
foreach (var cast in data.CastSkills)
{
if (cast.Slot == slot)
{
run.RecordSkillActivated(cast.SkillId, slot);
}
}
}
public void TakeDamage(float amount)
{
health = Math.Max(0f, health - amount);
if (health <= 0f)
{
Lose();
}
}
public void Win()
{
EndRun(ProjectVGameplayDataOutcome.Succeeded);
}
public void Lose()
{
EndRun(ProjectVGameplayDataOutcome.Failed);
}
public void GiveUp()
{
EndRun(ProjectVGameplayDataOutcome.Abandoned);
}
private void EndRun(ProjectVGameplayDataOutcome outcome)
{
if (run == null || ended || !run.OwnsAuthoritativeRun)
{
return;
}
// Locks the run: no more skill uses, no Restart.
var observed = run.ObserveOutcome(outcome, Math.Min(kills, 1024));
if (!observed.IsSuccess)
{
ShowResult(observed.SafeMessage, canRetry: false, canLeave: true);
return;
}
ended = true;
SendResult();
}
// Retry calls this again; the first call fixed the result.
private async void SendResult()
{
var current = run;
if (current == null)
{
return;
}
ShowResult("Sending the result...", canRetry: false, canLeave: false);
var result = await current.CompleteObservedAsync(health, armor, experience, CancellationToken.None);
if (this == null)
{
return;
}
if (!result.IsSuccess)
{
ShowResult(result.SafeMessage, canRetry: true, canLeave: false);
return;
}
ShowResult(Describe(result), canRetry: false, canLeave: true);
}
// Pause > Restart: ends the run without a result and without spending stamina.
public async void Restart()
{
var current = run;
if (current == null || ended)
{
return;
}
var result = await current.CancelRunAsync(CancellationToken.None);
if (this == null)
{
return;
}
if (result.IsSuccess)
{
SceneManager.LoadScene(campaignScene);
}
else
{
Debug.LogWarning(result.SafeMessage);
}
}
private static string Describe(ProjectVGameplayDataResult result)
{
var text = new StringBuilder();
var stars = result.Stars;
if (stars != null)
{
text.AppendLine($"{stars.Count}/{stars.Max} stars{(stars.IsNewBest ? " - new best!" : string.Empty)}");
}
foreach (var reward in result.Rewards)
{
text.AppendLine($"{reward.ResourceId} x{reward.Amount}");
}
if (result.MailedRewards.Count > 0)
{
text.AppendLine("Some rewards did not fit and were sent to your mailbox.");
}
return text.ToString();
}
private void ShowResult(string message, bool canRetry, bool canLeave)
{
resultPanel.SetActive(true);
resultText.text = message;
retryButton.gameObject.SetActive(canRetry);
leaveButton.gameObject.SetActive(canLeave);
}
}
Try itโ
Enter Play mode from the Login scene, sign in as Guest and open Campaign:
- the missions appear with their stars, entry stamina and lock labels; locked missions cannot start;
- a fast double tap on Start, or a tap on a second mission, loads the gameplay scene only once;
- a mission with no row in your scene table shows a message, and the Header stamina does not drop;
- in the gameplay scene, the character shows the stats from the run and one enemy appears per reward slot;
- winning shows the rewards and the stars; losing shows the rewards without stars;
- with the network off, the result screen shows Retry; turning it back on and tapping Retry shows the same result;
- Give up ends the run and costs the entry stamina; Restart goes back to Campaign and costs nothing;
- back in Campaign and the Lobby, the mission, stars, balances, stamina and level are up to date without a reload;
- leaving the gameplay scene gives no error in the Console.
What's nextโ
- The Campaign page and the Gameplay data page list every failure message and the common mistakes.
- All screens work the same way.
- The API reference has every member of these types.