Character
The Character screen shows the player's characters and allies, puts them in the loadout, levels them with EXP items, and upgrades their skill tree and their Weapon and Character skills. Levels, prices, EXP and what each upgrade gives are decided by the server.
| You use | It gives you |
|---|---|
IProjectVCharacterScreen | The screen contract: observe the view, select, level up, upgrade and reset |
ProjectVCharacterView | What to draw: loading state, the data, a pending level-up, upgrade state and EXP previews |
ProjectVCharacterData | Characters, loadout, skill trees, cast skills, inventory and balances |
ProjectVCharacterUpgradeState | Whether a character can level up now, and why not |
ProjectVCharacterSkillUpgradeDetails | Everything a skill tree Upgrade button needs |
ProjectVCharacterCastSkillUpgradeDetails | Everything a Weapon or Character skill Upgrade button needs |
ProjectVCharacterLevelPolicy | The "+1" and "Max" EXP pickers and the level reset preview |
ProjectVResult | The outcome of a command |
Equipment, backpack, skins, using other items and confirmation dialogs are not on the screen contract yet; see Presenter.
Quick startโ
using System;
using System.Collections.Generic;
using SilverTiger.ProjectV.Unity.Presentation.Character;
using UnityEngine;
using VContainer;
public sealed class CharacterPage : MonoBehaviour
{
private IProjectVCharacterScreen character = null!;
private IDisposable? observation;
private string selectedId = string.Empty;
[Inject]
public void Construct(IProjectVCharacterScreen screen) => character = screen;
private void OnEnable() => observation = character.Observe(Render);
private void OnDisable()
{
observation?.Dispose();
observation = null;
}
private void Render(ProjectVCharacterView view)
{
if (view.IsLoading || view.Data == null)
{
ShowLoading(view.LoadError); // LoadError is null while the first load is running
return;
}
if (view.Data.IsEmpty)
{
ShowEmpty();
return;
}
if (selectedId.Length == 0)
{
selectedId = view.Data.Characters[0].Id.Value;
}
ShowRoster(view.Data.Characters, view.Data.Loadout);
ShowUpgrade(view.UpgradeState(selectedId)!, view.PendingUpgrade != null);
}
public async void OnLevelUpClicked(IReadOnlyList<ProjectVCharacterXpItemSelection> items)
{
var result = await character.LevelUpAsync(selectedId, items);
if (this != null && !result.IsSuccess)
{
ShowMessage(result.Message);
}
}
private void ShowLoading(string? error) { }
private void ShowEmpty() { }
private void ShowRoster(IReadOnlyList<ProjectVCharacterEntry> characters, IReadOnlyList<ProjectVCharacterLoadoutSlot> loadout) { }
private void ShowUpgrade(ProjectVCharacterUpgradeState upgrade, bool retryOnly) { }
private void ShowMessage(string message) { }
}
That is the whole pattern: observe, draw, call a command. After a success
you do nothing; Render is called again with the new levels, ranks and
balances, and the Header shows the new wallet.
What to drawโ
Render receives a ProjectVCharacterView:
| Member | Type | Use it for |
|---|---|---|
IsLoading | bool | True until the data arrives the first time. Show a spinner |
LoadError | string? | Set when that first load failed. Show it with a Retry button that closes and reopens the page |
Data | ProjectVCharacterData? | Everything below; null while loading |
PendingUpgrade | ProjectVCharacterProgressionPending? | A level-up or level reset whose answer was lost. While it is set, show a "Retry" button; see Lost answers |
UpgradeState(characterId) | ProjectVCharacterUpgradeState? | The level-up panel; null while loading |
PreviewXp(characterId, items) | ProjectVCharacterXpUpgradePreview? | What leveling with these EXP items would do; null while loading |
From ProjectVCharacterData:
| To show | Read |
|---|---|
| The roster | Characters: Id, Kind (ProjectVCharacterKinds.Character or .Ally), Level, Experience, Stats |
| An empty roster | IsEmpty |
| Who is in the loadout | Loadout: SlotId and CharacterId per slot |
| A character's skill tree | GetSkills(characterId), or one node with GetSkill(characterId, skillId) |
| Unspent level skill points | LevelSkillPoints of that character's entry in SkillTrees |
| Weapon and Character skills | GetCastSkills(characterId) |
| The EXP item picker | XpItems (ItemId, Experience, CharacterKind, SortOrder) with the owned amount from Inventory |
| A balance or item count | GetResourceAmount(resourceId), or Balances and Inventory |
Stats has MaxHealth, AttackDamage, Armor, Energy,
RegenHealthDelay, RegenArmorDelay, RegenArmorInterval,
RegenArmorAmount, MoveSpeed, AttackSpeed and RangeTarget.
Match a character to your art and text with Id.Value in a table of your own
(ProjectV uses SOCharacter.V3CharacterId), never by display name. Use skill
IDs unchanged; they are 32 lowercase hexadecimal characters, such as
ad4b85fb7aa64ad196e1f7e0f330b0aa.
Select a characterโ
Task<ProjectVResult> SelectCharacterAsync(string slotId, string characterId)
| Parameter | Type | Value |
|---|---|---|
slotId | string | ProjectVLoadoutSlots.Main.Value or ProjectVLoadoutSlots.ActiveAlly.Value, or SlotId.Value from Loadout |
characterId | string | Id.Value of an owned character |
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed the change. The page redraws by itself |
false | the reason, ready to show | The slot did not change, or the change could not be confirmed |
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| The server refused the change | The server's message |
An empty slotId or characterId | "The request was not confirmed. Please try again." |
| No connection or a timeout | A network message. The page redraws with what the server has |
Level up with EXP itemsโ
Task<ProjectVResult> LevelUpAsync(string characterId, IReadOnlyList<ProjectVCharacterXpItemSelection> items)
| Parameter | Type | Value |
|---|---|---|
characterId | string | Id.Value of an owned character or ally |
items | IReadOnlyList<ProjectVCharacterXpItemSelection> | new ProjectVCharacterXpItemSelection(itemId, quantity) lines: at most 16, each item once, each quantity from 1 to 9999. May be empty to level with the EXP the character already has |
One tap, one level-up. The server spends the items, adds their EXP and takes every level that the EXP and the wallet cover. It stops at the maximum level, when the EXP runs out, or when the next level costs more than the player has; the leftover EXP is kept for later. It all happens together or not at all. EXP that does not reach the next level is still accepted and kept.
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed it. The page redraws with the new level and EXP; compare Level before and after to show "+3 levels" |
false | the reason, ready to show | Nothing was spent, or the answer was lost (see below) |
Throws at once, before anything is sent, when characterId is empty or
whitespace (ArgumentException), items is null (ArgumentNullException),
or items has a null line, more than 16 lines or the same item twice
(ArgumentException).
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| No items and not enough EXP | "Select enough EXP for the next level." |
| No items and not enough currency | "Not enough currency for the next level." |
| The character is at its maximum level | "Maximum level reached." |
| An item is not owned, not enough of it, or it is for the other kind (character or ally) | "The selected EXP items are unavailable for this character kind." |
| The server refused it | The server's message |
| No connection or a timeout | A network message. PendingUpgrade is set; see Lost answers |
When the selected items add EXP but do not reach the next level, or the wallet cannot pay for it, the call still succeeds: the EXP is added and kept.
Before the tapโ
var preview = view.PreviewXp(characterId, items);
levelUpButton.interactable = preview is { CanSubmit: true };
previewText.text = preview?.Message ?? string.Empty;
ProjectVCharacterXpUpgradePreview
gives GrantedExperience, ProjectedExperience, CurrentLevel,
TargetLevel, AvailableLevelCount (levels gained), the EXP bar values and CurrentStats /
TargetStats. TargetLevel is what the EXP reaches; the wallet may stop the
level-up earlier.
For the "+1" and "Max" buttons, let the SDK pick the items:
var nextLevel = ProjectVCharacterLevelPolicy.SelectForNextLevel(view.Data!, entry.Id);
var maximum = ProjectVCharacterLevelPolicy.SelectMaximum(view.Data!, entry.Id);
Both return the owned items in the server's item order, and an empty list
when there is nothing to pick. SelectForNextLevel may fall short when the
player owns too few.
The upgrade panelโ
var upgrade = view.UpgradeState(characterId)!;
upgradeButton.interactable = upgrade.CanUpgrade;
blockReasonText.text = upgrade.CanUpgrade ? string.Empty : upgrade.Message;
if (upgrade.Requirements is { } next)
{
xpText.text = $"{upgrade.Experience}/{next.RequiredExperience}";
RenderCosts(upgrade.Balances); // each cost line next to the wallet balance
}
| To show | Read |
|---|---|
| Whether Level Up is possible | CanUpgrade, and Message when it is not (for example "Not enough Character XP: 40/100.") |
| Why, as a value | BlockReason: None, NotOwned, Unavailable, Busy, SyncRequired, MaxLevel, InsufficientExperience, InsufficientFunds |
| The EXP bar | Experience / Requirements.RequiredExperience (both total EXP from level 1) |
| The price | Balances, or Requirements.Costs (it can be empty) |
| Before and after | Requirements.NextLevel and Requirements.NextStats |
UpgradeState describes taking the next level with the EXP the character
already has. When EXP items are selected, use PreviewXp instead.
Lost answersโ
If the connection drops during a level-up or level reset, PendingUpgrade
holds the character and the items. The next LevelUpAsync finishes that
action first and ignores the characterId and items you pass; the items
are never spent twice. This also works after the app restarts.
While PendingUpgrade is set, PreviewXp returns CanSubmit = false with
"Resume the pending Character progression first.", and a level reset is
refused with the same message. Show a single "Retry" button that calls
LevelUpAsync with the pending CharacterId and an empty list.
Upgrade a skillโ
Task<ProjectVResult> UpgradeSkillAsync(string characterId, string skillId)
| Parameter | Type | Value |
|---|---|---|
characterId | string | Id.Value of the character whose tree it is |
skillId | string | Id.Value of a skill in that character's tree |
Each character has its own tree. The same skill ID can have different ranks and unlock levels in different characters' trees.
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed the rank. The tree and balances redraw by themselves |
false | the reason, ready to show | No rank was bought |
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| Level, prerequisite or price not met | The server's message |
An empty or malformed characterId or skillId | "The request was not confirmed. Please try again." |
| No connection or a timeout | A network message. The page resynchronises by itself |
The skill Upgrade buttonโ
Use view.Data.GetSkillUpgradeDetails(characterId, skillId)
(ProjectVCharacterSkillUpgradeDetails):
| To show | Read |
|---|---|
| Enable Upgrade | CanUpgrade |
| Why not | BlockedMessage, every missing requirement in one line, for example Require Lv.20 ยท Core Dust 28/45, or "Maximum rank reached." |
| Rank | Skill.Rank / Skill.MaxRank, IsMaxed |
| The level needed | RequiredCharacterLevel against CharacterLevel |
| The price | Costs: one row per resource with DisplayName, Required, Owned, Missing, IsSatisfied; an empty list with IsFree = true means the rank is free |
| Required skills | Prerequisites: SkillId, RequiredRank, CurrentRank, IsSatisfied |
| Lock reasons | LockReasons: Prerequisite, CharacterLevel, Resources, Unknown |
Skill points come from two places: the character's own LevelSkillPoints
(one per level above 1, usable only in that character's tree) and the
wallet's skill_point. Costs[i].Owned already counts both.
ProjectVCharacterResourceNames.GetDisplayName gives the player-facing name
of a resource.
Reset the skill treeโ
Task<ProjectVResult> ResetSkillsAsync(string characterId)
| Parameter | Type | Value |
|---|---|---|
characterId | string | Id.Value of the character whose tree to reset |
Removes every rank of that character's tree only. What is given back is decided by the server.
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed the reset. The tree and balances redraw by themselves |
false | the reason, ready to show | Nothing was reset |
Throws ArgumentException at once when characterId is empty or
whitespace.
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| No skill in the tree has a rank | "There are no skill ranks to reset." (nothing is sent) |
| The server refused it | The server's message |
| No connection or a timeout | A network message. The page resynchronises by itself |
Disable Reset when no skill in GetSkills(characterId) has Rank above 0.
Upgrade a Weapon or Character skillโ
Task<ProjectVResult> UpgradeCastSkillAsync(string characterId, ProjectVCharacterCastSkillSlot slot)
| Parameter | Type | Value |
|---|---|---|
characterId | string | Id.Value of the character |
slot | ProjectVCharacterCastSkillSlot | Weapon or Character, never Unknown |
These are separate from the skill tree and go up one level per call.
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed the upgrade. The page redraws by itself |
false | the reason, ready to show | Nothing was upgraded |
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| Blocked: missing resources, maximum level, or a lock | The same text as BlockedMessage, such as "Maximum level reached." |
| The skill is not found or its data is incomplete | "Skill is unavailable. Sync to continue." |
| The data is being resynchronised | "Sync Character data before upgrading again." |
An empty characterId | "The request was not confirmed. Please try again." |
| The server refused it | The server's message |
The cast skill Upgrade buttonโ
Use view.Data.GetCastSkillUpgradeDetails(characterId, slot)
(ProjectVCharacterCastSkillUpgradeDetails):
| To show | Read |
|---|---|
| Enable Upgrade | CanUpgrade |
| Why not | BlockedMessage |
| Level | Level / MaxLevel (both start at 1), IsMaxed |
| The gameplay asset | CurrentSkillId now, NextSkillId after the upgrade (null at the maximum level) |
| The price | Costs: Kind (item or currency), DisplayName, Required, Owned, Missing, IsSatisfied |
A maxed track has no next ID and no costs. When something is unknown the upgrade stays blocked; never treat missing costs as a free upgrade.
Reset a levelโ
Task<ProjectVResult> ResetLevelAsync(string characterId)
| Parameter | Type | Value |
|---|---|---|
characterId | string | Id.Value of an owned character or ally |
Puts a character or an ally back at level 1 and gives back the EXP items it used, when the server allows it. Check it first:
var reset = ProjectVCharacterLevelPolicy.ResetPreview(view.Data!, entry.Id);
resetButton.interactable = reset.CanReset;
resetText.text = reset.Message; // "Ready to reset the level." or why not
ShowRefunds(reset.Refunds); // ItemId and Quantity per line
Returns a ProjectVResult:
IsSuccess | Message | What happened |
|---|---|---|
true | empty | The server confirmed the reset. The level and inventory redraw by themselves |
false | the reason, ready to show | Nothing was reset, or the answer was lost |
When it fails โ show Message as it is:
| Situation | What the player sees |
|---|---|
| The server does not allow it | The server's reason, the same as ResetPreview(...).Message |
| The character is not owned | "This character is not owned." |
| No reset information from the server | "Server reset requirements are unavailable." |
| A level-up or reset answer is still pending | "Resume the pending Character progression first." |
| The data is being resynchronised | "Sync Character data before resetting." |
| No connection or a timeout | A network message. PendingUpgrade is set; see Lost answers |
Taps and resynchronisingโ
- Commands run one at a time. A second tap on a running command returns the
same result instead of running twice. All level-ups share one slot: a
second
LevelUpAsyncwhile one runs, even for another character, returns the first one's result. - After a failed or unconfirmed result the screen resynchronises by itself and redraws. You do not need a Sync button.
- Commands other than
LevelUpAsyncandResetSkillsAsyncnever throw for bad input; they return "The request was not confirmed. Please try again."
Common mistakesโ
- Showing
result.Messageafter a successful level-up. It is empty on success. Read the newLevelfrom the nextRender. - Computing EXP needs, costs or the maximum level from local assets. Use
UpgradeState,PreviewXpand the upgrade details; when requirements are missing, keep the button disabled. - Using
Data.CanAffordfor a skill rank. It ignores the character's ownLevelSkillPoints. UseGetSkillUpgradeDetails(...).CanUpgradeor itsCosts. - Starting a new level-up while
PendingUpgradeis set. The call finishes the pending one instead; your new selection is not used. - Expecting mission EXP to level the character. The level and EXP earned inside a mission are for that run only. Character EXP comes from EXP items.
- Expecting changes to reach a running mission. Gameplay receives the
started character's skill ranks and Weapon and Character skills in
ProjectVGameplayStartingDataView; changing them here after the run starts does not affect it. - Using the result after the page closed. Check
this != nullafterawait. - Not disposing the observation. Keep the
IDisposablefromObserveand dispose it inOnDisableorOnDestroy.
Presenterโ
IProjectVCharacterPresenter
is the lower-level interface behind the screen contract. Use it only for what
the screen does not cover:
| Need | Presenter member |
|---|---|
| Equip or upgrade equipment | EquipEquipmentAsync, UnequipPrimaryWeaponAsync, UpgradeEquipmentAsync |
| Backpack | SetBackpackAsync(slotId, itemId, token); null empties the slot |
| Skins | EquipSkinAsync(skinId, token) |
| Other inventory items | UseInventoryItemAsync(itemId, quantity, token) (EXP items use LevelUpAsync) |
| Add EXP without taking a level | UseCharacterXpItemsAsync(characterId, items, token) |
| A price confirmation dialog | PrepareSkillUpgrade, PrepareEquipmentUpgrade, ConfirmPreparedUpgradeAsync, Transactions |
| Error code and request ID | Current.Error: Category, Code, RequestId, SafeMessage |
Presenter commands return a
ProjectVCharacterCommandResult
(IsSuccess, Snapshot). Listeners passed to SubscribeAsync may run off
the Unity main thread; switch to it before touching Unity objects. Observe
on the screen always renders on the main thread.
Confirmation dialogsโ
A confirmation is optional. A prepared skill upgrade remembers its character
and skill, so switching characters cannot change what it upgrades.
PrepareSkillUpgrade throws InvalidOperationException with the
BlockedMessage when the rank is not possible, so check CanUpgrade first.
presenter.PrepareSkillUpgrade(characterId, skillId); // fills presenter.Transactions.Current
ShowConfirm(presenter.Transactions.Current!); // title, price, balance preview
// on Confirm:
if (presenter.Transactions.CanConfirm)
{
var result = await presenter.ConfirmPreparedUpgradeAsync(token);
if (this != null && !result.IsSuccess)
{
ShowMessage(result.Snapshot.Error?.SafeMessage ?? string.Empty);
}
}
// on Cancel:
if (presenter.Transactions.CanCancel)
{
presenter.Transactions.Cancel();
}
presenter is the injected IProjectVCharacterPresenter; see
Transaction confirmation for the
dialog state.