Chuyển tới nội dung chính

Hướng dẫn: lên cấp cho nhân vật

Trong hướng dẫn này bạn dựng một trang Character: trang liệt kê các nhân vật của người chơi, hiện nhân vật đang chọn, chọn vật phẩm EXP bằng nút "+1" và "Max", xem trước kết quả và lên cấp. Cuối cùng, trang còn nâng một node của cây kỹ năng. Bạn sẽ dùng năm thứ:

Trước khi bắt đầu: người chơi đăng nhập và vào được Lobby (Hướng dẫn: Dựng luồng đăng nhập), và scene Character có một scope kế thừa từ ProjectVAuthenticatedSceneScope (Tích hợp scene). Hướng dẫn Shop dùng cùng cách làm trên một màn đơn giản hơn.

1. Đăng ký trang​

Trang là một MonoBehaviour trong scene Character. Đăng ký nó trong scene scope. Khi chưa ai đăng nhập, scope ẩn trang trước khi trang kịp theo dõi gì, rồi đưa người chơi về 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. Nhận screen và theo dõi nó​

Thêm một method [Inject] và giữ IProjectVCharacterScreen. Theo dõi khi trang đang mở: Observe(render) gọi Render ngay lập tức, rồi gọi lại mỗi khi dữ liệu thay đổi, luôn trên Unity main thread. Dispose (giải phóng) observation khi trang đóng.

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;
}

Bạn không tự tải gì cả. Nếu dữ liệu chưa được tải, việc theo dõi sẽ tải nó, và screen tự đồng bộ lại sau một command thất bại. Bạn không cần nút Sync.

3. Vẽ danh sách nhân vật và nhân vật đang chọn​

Khi view.IsLoading là true, view.Data là null: hiện spinner, hoặc view.LoadError khi lần tải đầu tiên thất bại. Sau đó vẽ Data.Characters thành một danh sách và nhớ nhân vật đang chọn theo 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 hiện Level, thanh EXP và vài chỉ số trong Stats. Thanh EXP là upgrade.Experience trên upgrade.Requirements.RequiredExperience; cả hai đều tính từ cấp 1. Requirements là null khi server không gửi, và RequiredExperience là 0 ở cấp tối đa.

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}";
}

Ánh xạ Id.Value sang tên và art của riêng bạn; SDK chỉ gửi ID và con số.

4. Chọn vật phẩm EXP bằng "+1" và "Max"​

Bạn không bao giờ tự tính lượng EXP cần. SelectForNextLevel chọn các vật phẩm người chơi đang có đủ cho cấp kế tiếp; SelectMaximum chọn các vật phẩm cho cấp cao nhất mà chúng cộng lại đạt được. Cả hai trả về danh sách rỗng khi không có gì để chọn, và các vật phẩm được chọn luôn hợp lệ để gửi.

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 chính là view mà Observe đưa cho bạn, nên trang vẽ lại ngay với lựa chọn mới. SelectForNextLevel có thể không đủ cho cấp kế tiếp khi người chơi có quá ít vật phẩm.

5. Hiện bản xem trước và lý do không được​

view.PreviewXp(characterId, items) cho biết lựa chọn đó sẽ làm gì: CurrentLevel, TargetLevel, lượng EXP các vật phẩm mang lại (GrantedExperience) và CanSubmit cho nút Level Up. Khi chưa chọn vật phẩm nào, view.UpgradeState(characterId) cho biết vì sao nhân vật chưa lên cấp được, trong 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 là cấp mà lượng EXP đạt tới; ví có thể chặn lượt lên cấp sớm hơn. Khi các vật phẩm cộng thêm EXP nhưng chưa đủ một cấp, CanSubmit vẫn là true: server giữ lại số EXP đó cho lần sau.

6. Lên cấp​

Gọi LevelUpAsync(characterId, items). Server lên mọi cấp mà lượng EXP và ví đủ trả, một lần cho tất cả.

  • IsSuccess là true: Message rỗng. So sánh Level trước và sau để hiện "+N levels". Trang tự vẽ lại với cấp mới.
  • IsSuccess là false: hiện Message; nó được viết sẵn cho người chơi.
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);
}

Mọi lượt lên cấp dùng chung một slot: lần gọi thứ hai trong lúc một lần đang chạy, kể cả cho nhân vật khác, nhận kết quả của lần gọi đầu. Cờ levelingUp giữ cho dòng "+N levels" nói đúng nhân vật. LevelUpAsync ném exception khi ID rỗng, danh sách null, quá 16 dòng hoặc cùng một vật phẩm hai lần; các lựa chọn của policy không bao giờ gặp những trường hợp đó.

7. Hoàn tất một lượt lên cấp bị mất​

Nếu mất kết nối trong lúc lên cấp, lần gọi thất bại với một thông báo lỗi mạng và view.PendingUpgrade được đặt. Lần LevelUpAsync kế tiếp hoàn tất lượt lên cấp đó trước và bỏ qua ID cùng các vật phẩm bạn truyền vào; vật phẩm không bao giờ bị tiêu hai lần. Hiện một nút thử lại (Retry) thay cho các nút chọn vật phẩm:

var pending = view.PendingUpgrade;
retryButton.gameObject.SetActive(pending != null);
plusOneButton.interactable = pending == null && !levelingUp;
maxButton.interactable = pending == null && !levelingUp;

Retry gọi LevelUpAsync qua LevelUp với nhân vật đang chờ và một danh sách rỗng, nên dòng "+N levels" so sánh đúng nhân vật:

private void RetryPending()
{
var pending = character.Current.PendingUpgrade;
if (pending != null)
{
LevelUp(pending.CharacterId, NoItems);
}
}

Trong lúc có một lượt lên cấp đang chờ, PreviewXp trả về CanSubmit false kèm thông báo yêu cầu hoàn tất lượt đó trước, nên nút Level Up vẫn bị tắt. Việc này vẫn đúng sau khi app khởi động lại.

8. Nâng một node của cây kỹ năng​

Mỗi nhân vật có cây kỹ năng riêng: Data.GetSkills(characterId). Với một node, Data.GetSkillUpgradeDetails(characterId, skillId) có mọi thứ nút Upgrade cần: bật nút theo CanUpgrade, và hiện BlockedMessage (ví dụ "Maximum rank reached.") khi nó là false.

var details = data.GetSkillUpgradeDetails(id, skill.Id);
skillText.text = $"Rank {skill.Rank}/{skill.MaxRank}";
skillButton.interactable = details.CanUpgrade;
skillBlockedText.text = details.BlockedMessage;

Sau đó gọi UpgradeSkillAsync(characterId, skillId). Khi thành công, cây kỹ năng và số dư tự vẽ lại; khi thất bại, hiện Message.

var result = await character.UpgradeSkillAsync(characterId, upgradeSkillId);
if (this == null || !isActiveAndEnabled)
{
return;
}
messageText.text = result.IsSuccess ? string.Empty : result.Message;

Trang này chỉ hiện node đầu tiên của cây. Một cây thật vẽ mọi node theo cùng cách.

Toàn bộ code​

Ba file của tutorial này, đầy đủ. CharacterRow là một prefab dòng đơn giản: một nhãn và một nút.

CharacterSceneScope.cs
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);
}
}
CharacterRow.cs
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());
}
}
CharacterPage.cs
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;
}
}

Chạy thử​

Vào Play mode từ scene Login, đăng nhập và mở scene Character:

  • danh sách nhân vật hiện ra (lần đầu có spinner hiện thoáng qua), và bấm vào một dòng thì hiện cấp, EXP và chỉ số của nhân vật đó;
  • khi chưa chọn vật phẩm nào, một nhân vật thiếu EXP hiện lý do chưa lên cấp được, ví dụ "Not enough Character XP: 40/100.";
  • "+1" và "Max" điền bản xem trước với cấp mục tiêu và lượng EXP nhận được;
  • Level Up với "Max" hiện "+N levels" và cấp mới, và các vật phẩm EXP rời khỏi kho đồ;
  • tắt mạng trong lúc lên cấp thì hiện thông báo lỗi mạng và nút Retry; bật mạng lại và bấm Retry thì lượt lên cấp hoàn tất mà vật phẩm không bị tiêu hai lần;
  • nút kỹ năng bị tắt kèm lý do khi không thể nâng hạng, và một hạng nâng được thì tăng lên một;
  • đóng trang trong lúc đang lên cấp không gây lỗi nào trong Console.

Tiếp theo​