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

Hướng dẫn: chơi một nhiệm vụ từ đầu đến cuối

Trong hướng dẫn này bạn theo một nhiệm vụ qua ba scene: trang Campaign bắt đầu nhiệm vụ, scene gameplay chơi nó và gửi kết quả, và Lobby hiện những gì đã thay đổi. Bạn sẽ dùng năm thứ:

Trước khi bắt đầu: hoàn thành hướng dẫn trước, Lên cấp cho nhân vật. Bạn cần một scene Campaign có scope kế thừa từ ProjectVAuthenticatedSceneScope (Tích hợp scene), và một scene gameplay trong build settings.

1. Đăng ký trang Campaign​

Trang Campaign là một MonoBehaviour trong scene Campaign. Đăng ký nó trong scene scope, giống như với Shop:

public sealed class CampaignSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<CampaignPage>();
}
}

2. Liệt kê các nhiệm vụ​

Inject IProjectVCampaignScreen, theo dõi nó trong OnEnable và dispose (giải phóng) observation trong OnDisable. Mỗi nhiệm vụ hiện số sao, stamina (thể lực) để vào và lý do bị khoá. Đọc trạng thái khoá từ view; đừng bao giờ tự suy ra từ tiến trình ở máy.

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

Hiện sao dạng Stars.BestStars / Stars.MaxStars; MaxStars là 0 với nhiệm vụ không có sao. MissionId.Value chỉ là một ID: ánh xạ nó sang tên và art của riêng bạn.

3. Bắt đầu một nhiệm vụ​

Gọi BeginMissionAsync(missionId). Khi thành công, ánh xạ GameplayKey.Value sang một scene bằng bảng của riêng bạn và load scene đó. Lúc này stamina để vào chỉ được kiểm tra; nó bị trừ khi lượt chơi kết thúc.

if (starting)
{
return;
}
starting = true;
var result = await campaign.BeginMissionAsync(mission.MissionId.Value);
if (this == null)
{
return;
}

Giữ cờ starting. Bấm lần hai vào cùng nhiệm vụ nhận kết quả của lần bấm đầu, còn bấm vào nhiệm vụ khác sẽ thất bại với "Another mission is starting."; không có cờ, trang có thể load một scene hai lần hoặc hiện lỗi đó. Nếu bảng của bạn không có scene cho nhiệm vụ đó, gọi CancelSceneLoadAsync(): lượt chơi được giải phóng và không tốn stamina. Làm tương tự nếu scene loader của riêng bạn thất bại.

4. Gắn lượt chơi trong scene gameplay​

Scene gameplay không cần scope. Thêm một component MissionBinding implement IProjectVGameplayDataConsumer. Khi scene load, SDK tìm thấy nó và gọi BindGameplayData trên Unity main thread. Giữ đúng một component như vậy trong scene, tính cả các object đang inactive; nếu không có hoặc có hai, không có gì được gắn.

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, không phải nhân vật được chọn ở máy, và áp dụng Stats thay cho giá trị trong asset của bạn. UnbindGameplayData được gọi khi lượt chơi kết thúc hoặc scene unload; xoá các tham chiếu của bạn ở đó, vì từ lúc đó mọi lần gọi trên presenter đều thất bại.

Một scene thật còn kiểm tra các asset kỹ năng trước khi bắt đầu chơi; xem Kiểm tra asset kỹ năng.

5. Theo dõi kẻ địch có phần thưởng​

Mỗi phần tử trong Drops là một kẻ địch có phần thưởng, với một SlotId. Đăng ký kẻ địch khi nó active, ghi nhận khi nó chết, và huỷ đăng ký khi nó bị deactivate. Lease (ở đây là spawnCount) phải thay đổi ở mỗi lần activate, để một kẻ địch dùng lại từ pool không bao giờ bị nhầm với lần sống trước của nó.

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) ghi nhận cái chết, và UnregisterDropOwner(registration) không huỷ ghi nhận đó. Bạn không bao giờ tự quyết định người chơi nhận được vật rơi nào; SDK và server làm việc đó dựa trên những gì bạn báo.

6. Ghi nhận lượt dùng kỹ năng​

Mỗi lần người chơi dùng một kỹ năng, báo SkillId của slot tương ứng trong CastSkills:

foreach (var cast in data.CastSkills)
{
if (cast.Slot == slot)
{
run.RecordSkillActivated(cast.SkillId, slot);
}
}

7. Kết thúc lượt chơi​

Khi người chơi thắng hoặc thua, gọi ObserveOutcome(outcome, kills) với số kẻ địch bị hạ từ 0 đến 1024. Nó khoá lượt chơi: từ lúc đó, lượt dùng kỹ năng và Restart đều bị từ chối. Sau đó gọi CompleteObservedAsync(health, armor, experience, token) và chỉ hiện những gì nó trả về.

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

Khi thành công, hiện Rewards, nhắc tới MailedRewards (những phần thưởng không vừa và đã được gửi vào hộp thư) và hiện Stars, giá trị này là null khi thua. Khi thất bại, giữ màn kết quả mở với nút thử lại (Retry), và Retry thực hiện đúng lần gọi đó. Lần gọi đầu tiên đã chốt kết quả; một lần thử lại gửi lại kết quả đó và bỏ qua giá trị mới.

8. Bỏ cuộc hoặc chơi lại​

Bỏ cuộc là một kết quả: báo nó là Abandoned và kết thúc lượt chơi như khi thua. Nó tốn stamina để vào.

Chơi lại không phải là một kết quả. Pause > Restart gọi CancelRunAsync, hàm này kết thúc lượt chơi mà không có kết quả và không tiêu stamina, rồi quay về Campaign. Nó bị từ chối khi ObserveOutcome đã được gọi.

var result = await current.CancelRunAsync(CancellationToken.None);
if (this != null && result.IsSuccess)
{
SceneManager.LoadScene(campaignScene);
}

9. Quay lại Lobby​

Load scene Campaign hoặc Lobby. Bạn không cần tải lại gì cả. Campaign đã hiện nhiệm vụ là hoàn thành với số sao tốt nhất mới, Header hiện số dư mới và stamina sau khi trừ phí vào, và Lobby hiện cấp của người chơi đúng như server đang lưu.

Toàn bộ code​

Năm file của tutorial này, đầy đủ. MissionItem là một prefab dòng có nhãn tiêu đề, sao, stamina và khoá, cùng một nút Start. RewardEnemy là một prefab kẻ địch có collider; bấm vào nó là hạ nó. Các nút thắng, thua, bỏ cuộc, chơi lại và kỹ năng của scene gameplay gọi các method public của MissionBinding; trong game của bạn, luật chơi của bạn gọi chúng.

CampaignSceneScope.cs
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);
}
}
MissionItem.cs
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());
}
}
CampaignPage.cs
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;
}
}
RewardEnemy.cs
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);
}
}
}
MissionBinding.cs
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);
}
}

Chạy thử​

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

  • các nhiệm vụ hiện ra với số sao, stamina để vào và nhãn khoá; nhiệm vụ bị khoá không bắt đầu được;
  • bấm hai lần thật nhanh vào Start, hoặc bấm vào một nhiệm vụ thứ hai, chỉ load scene gameplay một lần;
  • một nhiệm vụ không có dòng nào trong bảng scene của bạn hiện một thông báo, và stamina trên Header không giảm;
  • trong scene gameplay, nhân vật hiện chỉ số của lượt chơi và mỗi slot phần thưởng có một kẻ địch;
  • thắng thì hiện phần thưởng và số sao; thua thì hiện phần thưởng mà không có sao;
  • khi tắt mạng, màn kết quả hiện Retry; bật mạng lại và bấm Retry thì hiện đúng kết quả đó;
  • Give up kết thúc lượt chơi và tốn stamina để vào; Restart quay về Campaign và không tốn gì;
  • quay lại Campaign và Lobby, nhiệm vụ, số sao, số dư, stamina và cấp đều được cập nhật mà không cần tải lại;
  • rời scene gameplay không gây lỗi nào trong Console.

Tiếp theo​