Skip to main content

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 useIt gives you
IProjectVCharacterScreenThe screen contract: observe the view, select, level up, upgrade and reset
ProjectVCharacterViewWhat to draw: loading state, the data, a pending level-up, upgrade state and EXP previews
ProjectVCharacterDataCharacters, loadout, skill trees, cast skills, inventory and balances
ProjectVCharacterUpgradeStateWhether a character can level up now, and why not
ProjectVCharacterSkillUpgradeDetailsEverything a skill tree Upgrade button needs
ProjectVCharacterCastSkillUpgradeDetailsEverything a Weapon or Character skill Upgrade button needs
ProjectVCharacterLevelPolicyThe "+1" and "Max" EXP pickers and the level reset preview
ProjectVResultThe 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:

MemberTypeUse it for
IsLoadingboolTrue until the data arrives 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
DataProjectVCharacterData?Everything below; null while loading
PendingUpgradeProjectVCharacterProgressionPending?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 showRead
The rosterCharacters: Id, Kind (ProjectVCharacterKinds.Character or .Ally), Level, Experience, Stats
An empty rosterIsEmpty
Who is in the loadoutLoadout: SlotId and CharacterId per slot
A character's skill treeGetSkills(characterId), or one node with GetSkill(characterId, skillId)
Unspent level skill pointsLevelSkillPoints of that character's entry in SkillTrees
Weapon and Character skillsGetCastSkills(characterId)
The EXP item pickerXpItems (ItemId, Experience, CharacterKind, SortOrder) with the owned amount from Inventory
A balance or item countGetResourceAmount(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)
ParameterTypeValue
slotIdstringProjectVLoadoutSlots.Main.Value or ProjectVLoadoutSlots.ActiveAlly.Value, or SlotId.Value from Loadout
characterIdstringId.Value of an owned character

Returns a ProjectVResult:

IsSuccessMessageWhat happened
trueemptyThe server confirmed the change. The page redraws by itself
falsethe reason, ready to showThe slot did not change, or the change could not be confirmed

When it fails โ€” show Message as it is:

SituationWhat the player sees
The server refused the changeThe server's message
An empty slotId or characterId"The request was not confirmed. Please try again."
No connection or a timeoutA network message. The page redraws with what the server has

Level up with EXP itemsโ€‹

Task<ProjectVResult> LevelUpAsync(string characterId, IReadOnlyList<ProjectVCharacterXpItemSelection> items)
ParameterTypeValue
characterIdstringId.Value of an owned character or ally
itemsIReadOnlyList<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:

IsSuccessMessageWhat happened
trueemptyThe server confirmed it. The page redraws with the new level and EXP; compare Level before and after to show "+3 levels"
falsethe reason, ready to showNothing 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:

SituationWhat 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 itThe server's message
No connection or a timeoutA 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 showRead
Whether Level Up is possibleCanUpgrade, and Message when it is not (for example "Not enough Character XP: 40/100.")
Why, as a valueBlockReason: None, NotOwned, Unavailable, Busy, SyncRequired, MaxLevel, InsufficientExperience, InsufficientFunds
The EXP barExperience / Requirements.RequiredExperience (both total EXP from level 1)
The priceBalances, or Requirements.Costs (it can be empty)
Before and afterRequirements.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)
ParameterTypeValue
characterIdstringId.Value of the character whose tree it is
skillIdstringId.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:

IsSuccessMessageWhat happened
trueemptyThe server confirmed the rank. The tree and balances redraw by themselves
falsethe reason, ready to showNo rank was bought

When it fails โ€” show Message as it is:

SituationWhat the player sees
Level, prerequisite or price not metThe server's message
An empty or malformed characterId or skillId"The request was not confirmed. Please try again."
No connection or a timeoutA network message. The page resynchronises by itself

The skill Upgrade buttonโ€‹

Use view.Data.GetSkillUpgradeDetails(characterId, skillId) (ProjectVCharacterSkillUpgradeDetails):

To showRead
Enable UpgradeCanUpgrade
Why notBlockedMessage, every missing requirement in one line, for example Require Lv.20 ยท Core Dust 28/45, or "Maximum rank reached."
RankSkill.Rank / Skill.MaxRank, IsMaxed
The level neededRequiredCharacterLevel against CharacterLevel
The priceCosts: one row per resource with DisplayName, Required, Owned, Missing, IsSatisfied; an empty list with IsFree = true means the rank is free
Required skillsPrerequisites: SkillId, RequiredRank, CurrentRank, IsSatisfied
Lock reasonsLockReasons: 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)
ParameterTypeValue
characterIdstringId.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:

IsSuccessMessageWhat happened
trueemptyThe server confirmed the reset. The tree and balances redraw by themselves
falsethe reason, ready to showNothing was reset

Throws ArgumentException at once when characterId is empty or whitespace.

When it fails โ€” show Message as it is:

SituationWhat the player sees
No skill in the tree has a rank"There are no skill ranks to reset." (nothing is sent)
The server refused itThe server's message
No connection or a timeoutA 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)
ParameterTypeValue
characterIdstringId.Value of the character
slotProjectVCharacterCastSkillSlotWeapon or Character, never Unknown

These are separate from the skill tree and go up one level per call.

Returns a ProjectVResult:

IsSuccessMessageWhat happened
trueemptyThe server confirmed the upgrade. The page redraws by itself
falsethe reason, ready to showNothing was upgraded

When it fails โ€” show Message as it is:

SituationWhat the player sees
Blocked: missing resources, maximum level, or a lockThe 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 itThe server's message

The cast skill Upgrade buttonโ€‹

Use view.Data.GetCastSkillUpgradeDetails(characterId, slot) (ProjectVCharacterCastSkillUpgradeDetails):

To showRead
Enable UpgradeCanUpgrade
Why notBlockedMessage
LevelLevel / MaxLevel (both start at 1), IsMaxed
The gameplay assetCurrentSkillId now, NextSkillId after the upgrade (null at the maximum level)
The priceCosts: 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)
ParameterTypeValue
characterIdstringId.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:

IsSuccessMessageWhat happened
trueemptyThe server confirmed the reset. The level and inventory redraw by themselves
falsethe reason, ready to showNothing was reset, or the answer was lost

When it fails โ€” show Message as it is:

SituationWhat the player sees
The server does not allow itThe 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 timeoutA 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 LevelUpAsync while 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 LevelUpAsync and ResetSkillsAsync never throw for bad input; they return "The request was not confirmed. Please try again."

Common mistakesโ€‹

  • Showing result.Message after a successful level-up. It is empty on success. Read the new Level from the next Render.
  • Computing EXP needs, costs or the maximum level from local assets. Use UpgradeState, PreviewXp and the upgrade details; when requirements are missing, keep the button disabled.
  • Using Data.CanAfford for a skill rank. It ignores the character's own LevelSkillPoints. Use GetSkillUpgradeDetails(...).CanUpgrade or its Costs.
  • Starting a new level-up while PendingUpgrade is 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 != null after await.
  • Not disposing the observation. Keep the IDisposable from Observe and dispose it in OnDisable or OnDestroy.

Presenterโ€‹

IProjectVCharacterPresenter is the lower-level interface behind the screen contract. Use it only for what the screen does not cover:

NeedPresenter member
Equip or upgrade equipmentEquipEquipmentAsync, UnequipPrimaryWeaponAsync, UpgradeEquipmentAsync
BackpackSetBackpackAsync(slotId, itemId, token); null empties the slot
SkinsEquipSkinAsync(skinId, token)
Other inventory itemsUseInventoryItemAsync(itemId, quantity, token) (EXP items use LevelUpAsync)
Add EXP without taking a levelUseCharacterXpItemsAsync(characterId, items, token)
A price confirmation dialogPrepareSkillUpgrade, PrepareEquipmentUpgrade, ConfirmPreparedUpgradeAsync, Transactions
Error code and request IDCurrent.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.