Tutorial: level up a character
In this tutorial you build a Character page: it lists the player's characters, shows the selected one, picks EXP items with "+1" and "Max" buttons, previews the result and levels up. At the end it also upgrades one skill-tree node. You will use five things:
IProjectVCharacterScreen: what the page talks to;ProjectVCharacterView: what the page draws;ProjectVCharacterLevelPolicy: picks the EXP items for you;ProjectVCharacterXpUpgradePreview: what a level-up would do;ProjectVResult: what a command returns.
Before you start: the player can sign in and reach the Lobby
(Tutorial: Build the Login Flow), and the Character scene has a scope derived from
ProjectVAuthenticatedSceneScope
(Scene integration).
The Shop tutorial uses the same pattern on a simpler screen.
1. Register the page​
The page is a MonoBehaviour in the Character scene. Register it in the
scene scope. When nobody is signed in, the scope hides the page before it
can observe anything, then sends the player to Login.
public sealed class CharacterSceneScope : ProjectVAuthenticatedSceneScope
{
[SerializeField] private CharacterPage page = null!;
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<CharacterPage>();
}
protected override void DisableScenePresentation()
{
page.gameObject.SetActive(false);
}
protected override void NavigateToLogin(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
}
2. Receive the screen and observe it​
Add an [Inject] method and keep the IProjectVCharacterScreen. Observe
while the page is open: Observe(render) calls Render right away, then
again each time the data changes, always on the Unity main thread. Dispose
the observation when the page closes.
private IProjectVCharacterScreen character = null!;
private IDisposable? observation;
[Inject]
public void Construct(IProjectVCharacterScreen screen)
{
character = screen ?? throw new ArgumentNullException(nameof(screen));
}
private void OnEnable()
{
observation = character.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
You do not load anything yourself. If the data is not loaded yet, observing loads it, and the screen resynchronises by itself after a failed command. You do not need a Sync button.
3. Draw the roster and the selected character​
While view.IsLoading is true, view.Data is null: show a spinner, or
view.LoadError when the first load failed. Then draw Data.Characters as a
list and remember the selected character by Id.Value.
private void Render(ProjectVCharacterView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
if (view.IsLoading || view.Data == null)
{
messageText.text = view.LoadError ?? string.Empty;
return;
}
if (view.Data.IsEmpty)
{
messageText.text = "No characters yet.";
return;
}
var selected = Find(view.Data, selectedId) ?? view.Data.Characters[0];
if (selected.Id.Value != selectedId)
{
selectedId = selected.Id.Value;
selectedItems = NoItems;
}
var upgrade = view.UpgradeState(selectedId)!;
DrawRoster(view.Data);
DrawSelected(selected, upgrade);
DrawLevelUp(view, upgrade);
DrawSkill(view.Data);
}
DrawSelected shows Level, the EXP bar and a few Stats. The EXP bar is
upgrade.Experience out of upgrade.Requirements.RequiredExperience; both
count from level 1. Requirements is null when the server sent none, and
RequiredExperience is 0 at the maximum level.
private void DrawSelected(ProjectVCharacterEntry entry, ProjectVCharacterUpgradeState upgrade)
{
levelText.text = $"Lv.{entry.Level}";
xpText.text = upgrade.Requirements is { RequiredExperience: > 0 } next
? $"EXP {upgrade.Experience}/{next.RequiredExperience}"
: $"EXP {upgrade.Experience}";
var stats = entry.Stats;
statsText.text = $"HP {stats.MaxHealth} · ATK {stats.AttackDamage} · Armor {stats.Armor}";
}
Map Id.Value to your own names and art; the SDK sends only IDs and numbers.
4. Pick EXP items with "+1" and "Max"​
You never compute EXP needs yourself. SelectForNextLevel picks the owned
items that cover the next level; SelectMaximum picks the items for the
highest level they can reach together. Both return an empty list when there
is nothing to pick, and the items are always valid to send.
private void Pick(bool maximum)
{
var data = character.Current.Data;
if (data == null)
{
return;
}
var id = new ProjectVCharacterId(selectedId);
selectedItems = maximum
? ProjectVCharacterLevelPolicy.SelectMaximum(data, id)
: ProjectVCharacterLevelPolicy.SelectForNextLevel(data, id);
Render(character.Current);
}
character.Current is the same view Observe gives you, so the page redraws
with the new selection at once. SelectForNextLevel may fall short of the
next level when the player owns too few items.
5. Show the preview and why not​
view.PreviewXp(characterId, items) says what the selection would do:
CurrentLevel, TargetLevel, the EXP the items give (GrantedExperience)
and CanSubmit for the Level Up button. With no items selected,
view.UpgradeState(characterId) says why the character cannot level now, in
Message.
var preview = view.PreviewXp(selectedId, selectedItems)!;
levelUpButton.interactable = !levelingUp && preview.CanSubmit;
var gain = preview.GrantedExperience > 0 ? $" · EXP +{preview.GrantedExperience}" : string.Empty;
previewText.text = $"Lv.{preview.CurrentLevel} → Lv.{preview.TargetLevel}{gain}";
reasonText.text = selectedItems.Count > 0
? preview.Message
: upgrade.CanUpgrade ? string.Empty : upgrade.Message;
TargetLevel is what the EXP reaches; the wallet may stop the level-up
earlier. When the items add EXP but not a full level, CanSubmit is still
true: the server keeps the EXP for later.
6. Level up​
Call LevelUpAsync(characterId, items). The server takes every level that
the EXP and the wallet cover, all at once.
IsSuccesstrue:Messageis empty. CompareLevelbefore and after to show "+N levels". The page redraws with the new level by itself.IsSuccessfalse: showMessage; it is written for the player.
private async void LevelUp(string characterId, IReadOnlyList<ProjectVCharacterXpItemSelection> items)
{
if (characterId.Length == 0 || levelingUp)
{
return;
}
var before = LevelOf(characterId);
levelingUp = true;
levelUpButton.interactable = false;
ProjectVResult result;
try
{
result = await character.LevelUpAsync(characterId, items);
}
finally
{
levelingUp = false;
}
// The page may have closed while the level-up ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (result.IsSuccess)
{
var gained = LevelOf(characterId) - before;
messageText.text = gained > 1 ? $"+{gained} levels" : gained == 1 ? "+1 level" : gained < 0 ? "Level reset." : "EXP added.";
selectedItems = NoItems;
}
else
{
messageText.text = result.Message;
}
Render(character.Current);
}
All level-ups share one slot: a second call while one runs, even for another
character, returns the first call's result. The levelingUp flag keeps the
"+N levels" text about the right character. LevelUpAsync throws for an
empty ID, a null list, more than 16 lines or the same item twice; the
policy's picks never do that.
7. Finish a lost level-up​
If the connection drops during a level-up, the call fails with a network
message and view.PendingUpgrade is set. The next LevelUpAsync finishes
that level-up first and ignores the ID and items you pass; the items are
never spent twice. Show one "Retry" button instead of the pickers:
var pending = view.PendingUpgrade;
retryButton.gameObject.SetActive(pending != null);
plusOneButton.interactable = pending == null && !levelingUp;
maxButton.interactable = pending == null && !levelingUp;
Retry calls LevelUpAsync through LevelUp with the pending character and
an empty list, so the "+N levels" text compares the right character:
private void RetryPending()
{
var pending = character.Current.PendingUpgrade;
if (pending != null)
{
LevelUp(pending.CharacterId, NoItems);
}
}
While a level-up is pending, PreviewXp returns CanSubmit false with a
message to resume first, so Level Up stays disabled. This also works after
the app restarts.
8. Upgrade a skill-tree node​
Each character has its own tree: Data.GetSkills(characterId). For one
node, Data.GetSkillUpgradeDetails(characterId, skillId) has everything the
Upgrade button needs: enable it from CanUpgrade, and show
BlockedMessage (for example "Maximum rank reached.") when it is false.
var details = data.GetSkillUpgradeDetails(id, skill.Id);
skillText.text = $"Rank {skill.Rank}/{skill.MaxRank}";
skillButton.interactable = details.CanUpgrade;
skillBlockedText.text = details.BlockedMessage;
Then call UpgradeSkillAsync(characterId, skillId). On success the tree and
the balances redraw by themselves; on failure show Message.
var result = await character.UpgradeSkillAsync(characterId, upgradeSkillId);
if (this == null || !isActiveAndEnabled)
{
return;
}
messageText.text = result.IsSuccess ? string.Empty : result.Message;
This page shows only the first node of the tree. A real tree draws every node the same way.
Full code​
The three files of this tutorial, complete. CharacterRow is a simple row
prefab: a label and a button.
using SilverTiger.ProjectV.Unity.Presentation;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;
public sealed class CharacterSceneScope : ProjectVAuthenticatedSceneScope
{
[SerializeField] private CharacterPage page = null!;
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<CharacterPage>();
}
protected override void DisableScenePresentation()
{
page.gameObject.SetActive(false);
}
protected override void NavigateToLogin(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
}
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public sealed class CharacterRow : MonoBehaviour
{
[SerializeField] private TMP_Text label = null!;
[SerializeField] private Button button = null!;
public void Show(string text, bool selected, Action onClick)
{
label.text = selected ? $"> {text}" : text;
button.onClick.AddListener(() => onClick());
}
}
using System;
using System.Collections.Generic;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Character;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
public sealed class CharacterPage : MonoBehaviour
{
private static readonly IReadOnlyList<ProjectVCharacterXpItemSelection> NoItems =
Array.Empty<ProjectVCharacterXpItemSelection>();
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMP_Text messageText = null!;
[SerializeField] private CharacterRow rowPrefab = null!;
[SerializeField] private Transform list = null!;
[SerializeField] private TMP_Text levelText = null!;
[SerializeField] private TMP_Text xpText = null!;
[SerializeField] private TMP_Text statsText = null!;
[SerializeField] private TMP_Text previewText = null!;
[SerializeField] private TMP_Text reasonText = null!;
[SerializeField] private Button plusOneButton = null!;
[SerializeField] private Button maxButton = null!;
[SerializeField] private Button levelUpButton = null!;
[SerializeField] private Button retryButton = null!;
[SerializeField] private TMP_Text skillText = null!;
[SerializeField] private TMP_Text skillBlockedText = null!;
[SerializeField] private Button skillButton = null!;
private IProjectVCharacterScreen character = null!;
private IDisposable? observation;
private string selectedId = string.Empty;
private IReadOnlyList<ProjectVCharacterXpItemSelection> selectedItems = NoItems;
private string skillId = string.Empty;
private bool levelingUp;
[Inject]
public void Construct(IProjectVCharacterScreen screen)
{
character = screen ?? throw new ArgumentNullException(nameof(screen));
}
private void Awake()
{
plusOneButton.onClick.AddListener(() => Pick(false));
maxButton.onClick.AddListener(() => Pick(true));
levelUpButton.onClick.AddListener(() => LevelUp(selectedId, selectedItems));
retryButton.onClick.AddListener(RetryPending);
skillButton.onClick.AddListener(UpgradeSkill);
}
private void OnEnable()
{
observation = character.Observe(Render);
}
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
private void Render(ProjectVCharacterView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
if (view.IsLoading || view.Data == null)
{
messageText.text = view.LoadError ?? string.Empty;
return;
}
if (view.Data.IsEmpty)
{
messageText.text = "No characters yet.";
return;
}
var selected = Find(view.Data, selectedId) ?? view.Data.Characters[0];
if (selected.Id.Value != selectedId)
{
selectedId = selected.Id.Value;
selectedItems = NoItems;
}
var upgrade = view.UpgradeState(selectedId)!;
DrawRoster(view.Data);
DrawSelected(selected, upgrade);
DrawLevelUp(view, upgrade);
DrawSkill(view.Data);
}
private void DrawRoster(ProjectVCharacterData data)
{
foreach (Transform child in list)
{
Destroy(child.gameObject);
}
foreach (var entry in data.Characters)
{
var id = entry.Id.Value;
var kind = entry.Kind.Equals(ProjectVCharacterKinds.Ally) ? "Ally" : "Character";
var row = Instantiate(rowPrefab, list);
row.Show($"{id} · {kind} · Lv.{entry.Level}", id == selectedId, () => Select(id));
}
}
private void DrawSelected(ProjectVCharacterEntry entry, ProjectVCharacterUpgradeState upgrade)
{
levelText.text = $"Lv.{entry.Level}";
xpText.text = upgrade.Requirements is { RequiredExperience: > 0 } next
? $"EXP {upgrade.Experience}/{next.RequiredExperience}"
: $"EXP {upgrade.Experience}";
var stats = entry.Stats;
statsText.text = $"HP {stats.MaxHealth} · ATK {stats.AttackDamage} · Armor {stats.Armor}";
}
private void DrawLevelUp(ProjectVCharacterView view, ProjectVCharacterUpgradeState upgrade)
{
var pending = view.PendingUpgrade;
retryButton.gameObject.SetActive(pending != null);
plusOneButton.interactable = pending == null && !levelingUp;
maxButton.interactable = pending == null && !levelingUp;
var preview = view.PreviewXp(selectedId, selectedItems)!;
levelUpButton.interactable = !levelingUp && preview.CanSubmit;
if (pending != null)
{
previewText.text = string.Empty;
reasonText.text = "The last level-up was not confirmed. Tap Retry.";
return;
}
var gain = preview.GrantedExperience > 0 ? $" · EXP +{preview.GrantedExperience}" : string.Empty;
previewText.text = $"Lv.{preview.CurrentLevel} → Lv.{preview.TargetLevel}{gain}";
reasonText.text = selectedItems.Count > 0
? preview.Message
: upgrade.CanUpgrade ? string.Empty : upgrade.Message;
}
private void DrawSkill(ProjectVCharacterData data)
{
var id = new ProjectVCharacterId(selectedId);
var skills = data.GetSkills(id);
skillButton.gameObject.SetActive(skills.Count > 0);
if (skills.Count == 0)
{
skillId = string.Empty;
skillText.text = "No skill tree.";
skillBlockedText.text = string.Empty;
return;
}
var skill = skills[0];
var details = data.GetSkillUpgradeDetails(id, skill.Id);
skillId = skill.Id.Value;
skillText.text = $"Rank {skill.Rank}/{skill.MaxRank}";
skillButton.interactable = details.CanUpgrade;
skillBlockedText.text = details.BlockedMessage;
}
private void Select(string characterId)
{
selectedId = characterId;
selectedItems = NoItems;
messageText.text = string.Empty;
Render(character.Current);
}
private void Pick(bool maximum)
{
var data = character.Current.Data;
if (data == null)
{
return;
}
var id = new ProjectVCharacterId(selectedId);
selectedItems = maximum
? ProjectVCharacterLevelPolicy.SelectMaximum(data, id)
: ProjectVCharacterLevelPolicy.SelectForNextLevel(data, id);
Render(character.Current);
}
private async void LevelUp(string characterId, IReadOnlyList<ProjectVCharacterXpItemSelection> items)
{
if (characterId.Length == 0 || levelingUp)
{
return;
}
var before = LevelOf(characterId);
levelingUp = true;
levelUpButton.interactable = false;
ProjectVResult result;
try
{
result = await character.LevelUpAsync(characterId, items);
}
finally
{
levelingUp = false;
}
// The page may have closed while the level-up ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (result.IsSuccess)
{
var gained = LevelOf(characterId) - before;
messageText.text = gained > 1 ? $"+{gained} levels" : gained == 1 ? "+1 level" : gained < 0 ? "Level reset." : "EXP added.";
selectedItems = NoItems;
}
else
{
messageText.text = result.Message;
}
Render(character.Current);
}
private void RetryPending()
{
var pending = character.Current.PendingUpgrade;
if (pending != null)
{
LevelUp(pending.CharacterId, NoItems);
}
}
private async void UpgradeSkill()
{
var characterId = selectedId;
var upgradeSkillId = skillId;
if (characterId.Length == 0 || upgradeSkillId.Length == 0)
{
return;
}
skillButton.interactable = false;
var result = await character.UpgradeSkillAsync(characterId, upgradeSkillId);
if (this == null || !isActiveAndEnabled)
{
return;
}
messageText.text = result.IsSuccess ? string.Empty : result.Message;
Render(character.Current);
}
private long LevelOf(string characterId)
{
var data = character.Current.Data;
var entry = data == null ? null : Find(data, characterId);
return entry?.Level ?? 0;
}
private static ProjectVCharacterEntry? Find(ProjectVCharacterData data, string characterId)
{
foreach (var entry in data.Characters)
{
if (entry.Id.Value == characterId)
{
return entry;
}
}
return null;
}
}
Try it​
Enter Play mode from the Login scene, sign in and open the Character scene:
- the roster appears (a spinner shows briefly the first time), and tapping a row shows that character's level, EXP and stats;
- with no items selected, a character short of EXP shows why it cannot level, such as "Not enough Character XP: 40/100.";
- "+1" and "Max" fill the preview with the target level and the EXP gained;
- Level Up with "Max" shows "+N levels" and the new level, and the EXP items leave the inventory;
- turning the network off during a level-up shows a network message and the Retry button; turning it back on and tapping Retry finishes it without spending the items twice;
- the skill button is disabled with a reason when the rank is not possible, and a possible rank goes up by one;
- closing the page during a level-up gives no error in the Console.
What's next​
- The Character page lists every failure message, lost answers and the common mistakes.
- The API reference has every member of these types.
- Next: Tutorial: Play a mission end to end.