Desenvolvimento de um Sistema de Missões em Unity com ScriptableObjects e UI Dinâmica

Arquitetura de Dados e ScriptableObjects

Para construir um sistema de missões robusto e desacoplado, utilizaremos ScriptableObjects para definir os perfis das missões. Este sistema será integrado a um sistema de diálogos pré-existente, permitindo que NPCs ofereçam, rastreiem e concluam missões.

Definição do Perfil da Missão

A classe principal QuestProfile armazena os metadados da missão. Em vez de uma lista simples, encapsulamos os objetivos em uma classe serializável interna. O estado da missão é gerenciado por uma propriedade que atualiza automaticamente o status quando os objetivos são concluídos.

[CreateAssetMenu(fileName = "NovoPerfilMissao", menuName = "RPG/Missoes/Perfil")]
public class QuestProfile : ScriptableObject
{
    [System.Serializable]
    public class QuestObjective
    {
        public string targetIdentifier;
        public int requiredCount;
        public int currentCount;
    }
    
    public string questTitle;
    [TextArea] public string questLore;

    public QuestStatus CurrentStatus { get; private set; }
    
    public List<QuestObjective> objectives = new();
    public List<InventoryItem> lootRewards = new();

    private int pendingObjectives;

    public int PendingObjectives
    {
        get => pendingObjectives;
        set
        {
            pendingObjectives = Mathf.Clamp(value, 0, objectives.Count);
            CurrentStatus = pendingObjectives == 0 ? QuestStatus.ReadyToTurnIn : QuestStatus.InProgress;
        }
    }

    public void DistributeRewards()
    {
        foreach (var reward in lootRewards)
        {
            if (reward.itemAmount < 0)
            {
                int deductionAmount = Mathf.Abs(reward.itemAmount);
                InventorySystem.Instance.DeductQuestItems(reward, deductionAmount);
            }
            else
            {
                InventorySystem.Instance.AddQuestReward(reward);
            }
        }
        InventorySystem.Instance.RefreshUIContainers();
    }
    
    public void InitializeRuntimeState()
    {
        CurrentStatus = QuestStatus.InProgress;
        PendingObjectives = objectives.Count;
    }
}

Integração com o Sistema de Diálogos

Para conectar as missões às conversas, adicionamos referências de QuestProfile aos nós de diálogo e um booleano nas opções para indicar interações de missão (aceitar ou entregar).

private void HandleDialogueOptionSelection()
{
    if (isQuestInteraction && selectedDialogueNode.linkedQuest != null)
    {
        QuestProfile runtimeQuest = QuestRegistry.Instance.FetchQuest(selectedDialogueNode.linkedQuest);
        
        if (runtimeQuest != null) 
        {
            if (runtimeQuest.CurrentStatus == QuestStatus.ReadyToTurnIn)
            {
                runtimeQuest.CurrentStatus = QuestStatus.TurnedIn;
                runtimeQuest.DistributeRewards();
            }
        }
        else
        {
            QuestRegistry.Instance.RegisterNewQuest(selectedDialogueNode.linkedQuest);
            foreach (var obj in selectedDialogueNode.linkedQuest.objectives)
            {
                InventorySystem.Instance.SyncInventoryWithQuest(obj.targetIdentifier);
            }
        }
    }
    
    NavigateToNextDialogueNode();
}

Gerenciamento de Estado e Persistência

O QuestRegistry atua como o gerenciador centrall. Para otimizar a busca e evitar iterações desnecessárias, utilizamos um Dictionary em vez de uma List. Ele também implementa a interface de persistência para salvar e carregar o progresso, garantindo que as instâncias em tempo de execução sejam clonadas para não corromper os dados do editor.

public class QuestRegistry : Singleton<QuestRegistry>, IGamePersistable
{
    private Dictionary<string, QuestProfile> activeQuests = new();

    public void RegisterNewQuest(QuestProfile template)
    {
        if (activeQuests.ContainsKey(template.questTitle)) return;
        
        QuestProfile runtimeInstance = Instantiate(template);
        runtimeInstance.InitializeRuntimeState();
        activeQuests.Add(runtimeInstance.questTitle, runtimeInstance);
    }

    public void AdvanceObjective(string targetId, int amount = 1)
    {
        foreach (var quest in activeQuests.Values)
        {
            if (quest.CurrentStatus == QuestStatus.TurnedIn) continue;
            
            var matchedObj = quest.objectives.Find(o => o.targetIdentifier == targetId);
            if (matchedObj != null)
            {
                bool wasCompleted = matchedObj.currentCount >= matchedObj.requiredCount;
                matchedObj.currentCount += amount;
                bool isNowCompleted = matchedObj.currentCount >= matchedObj.requiredCount;

                if (!wasCompleted && isNowCompleted) quest.PendingObjectives--;
                else if (wasCompleted && !isNowCompleted) quest.PendingObjectives++;
            }
        }
    }
    
    public QuestProfile FetchQuest(QuestProfile template)
    {
        activeQuests.TryGetValue(template.questTitle, out var quest);
        return quest;
    }

    public string GetPersistableId() => "QuestRegistry";

    public void SaveState(GameData data)
    {
        data.savedQuests.Clear();
        foreach (var quest in activeQuests.Values)
            data.savedQuests.Add(Instantiate(quest));
    }

    public void LoadState(GameData data)
    {
        activeQuests.Clear();
        foreach (var quest in data.savedQuests)
            activeQuests.Add(quest.questTitle, Instantiate(quest));
    }
}

Construção da Interface de Usuário

A interface é dividida em componentes modulares. O QuestBoardUI gerencia o painel principal, limpando e reinstanciando os elementos dinâmicos para garantir que a UI reflita o estado atual do jogo.

public class QuestBoardUI : Singleton<QuestBoardUI>
{
    [SerializeField] private GameObject questBoardPanel;
    [SerializeField] public ItemTooltip tooltipHandler;
    
    [SerializeField] private Transform questListContainer;
    [SerializeField] private QuestEntryButton entryButtonPrefab;
    
    [SerializeField] private Transform objectivesContainer;
    [SerializeField] private ObjectiveTrackerUI objectivePrefab;
    
    [SerializeField] private Transform rewardsContainer;
    [SerializeField] private ItemSlotUI rewardSlotPrefab;
    
    [SerializeField] private TextMeshProUGUI loreText;
    
    public void OpenBoard()
    {
        questBoardPanel.SetActive(true);
        loreText.text = string.Empty;
        RebuildQuestList();
    }
    
    private void RebuildQuestList()
    {
        ClearContainer(questListContainer);
        ClearContainer(objectivesContainer);
        ClearContainer(rewardsContainer);
        
        foreach (var quest in QuestRegistry.Instance.GetAllQuests())
        {
            var btn = Instantiate(entryButtonPrefab, questListContainer);
            btn.ConfigureButton(quest, OnQuestSelected);
        }
    }

    private void ClearContainer(Transform container)
    {
        for (int i = container.childCount - 1; i >= 0; i--)
            Destroy(container.GetChild(i).gameObject);
    }
    
    public void DisplayQuestDetails(QuestProfile quest)
    {
        loreText.text = quest.questLore;
        RebuildObjectives(quest);
        RebuildRewards(quest);
    }

    private void RebuildObjectives(QuestProfile quest)
    {
        ClearContainer(objectivesContainer);
        foreach (var obj in quest.objectives)
        {
            var ui = Instantiate(objectivePrefab, objectivesContainer);
            ui.Configure(quest.CurrentStatus == QuestStatus.TurnedIn, obj);
        }
    }
    
    private void RebuildRewards(QuestProfile quest)
    {
        ClearContainer(rewardsContainer);
        foreach (var reward in quest.lootRewards)
        {
            var slot = Instantiate(rewardSlotPrefab, rewardsContainer);
            slot.Configure(reward.itemData, Mathf.Abs(reward.itemAmount));
        }
    }
    
    private void OnQuestSelected(QuestProfile quest) => DisplayQuestDetails(quest);
}

Componentes de UI Auxiliares

O botão de entrada da missão atualiza o texto com base no estado e notifica o gerenciador principal. O rastreador de objetivos formata a barra de progresso ou o texto de conclusão.

public class QuestEntryButton : MonoBehaviour
{
   [SerializeField] private TextMeshProUGUI titleText;
   private QuestProfile boundQuest;
   private Action<QuestProfile> onSelect;

   private void Awake() => GetComponent<Button>().onClick.AddListener(() => onSelect?.Invoke(boundQuest));

   public void ConfigureButton(QuestProfile quest, Action<QuestProfile> callback)
   {
      boundQuest = quest;
      onSelect = callback;
      string suffix = quest.CurrentStatus switch
      {
          QuestStatus.InProgress => " [Em Andamento]",
          QuestStatus.ReadyToTurnIn => " [Pronta]",
          QuestStatus.TurnedIn => " [Concluída]",
          _ => ""
      };
      titleText.text = quest.questTitle + suffix;
   }
}

public class ObjectiveTrackerUI : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI objectiveTitle;
    [SerializeField] private TextMeshProUGUI progressText;

    public void Configure(bool isTurnedIn, QuestProfile.QuestObjective obj)
    {
        objectiveTitle.text = obj.targetIdentifier;
        if (isTurnedIn)
        {
            progressText.text = "Concluído";
            SetTextColor(Color.gray);
        }
        else
        {
            progressText.text = $"{obj.currentCount}/{obj.requiredCount}";
            SetTextColor(Color.white);
        }
    }

    private void SetTextColor(Color color)
    {
        objectiveTitle.color = color;
        progressText.color = color;
    }
}

Rastreamento de Progresso e Recompensas

O progresso é atualizado via eventos globais (ex: coleta de itens, abate de inimigos). O inventário também deve ser sincronizado no momento em que a missão é aceita, verificando itens já possuídos pelo jogador.

// No InventorySystem
public void SyncInventoryWithQuest(string targetId)
{
    CheckAndSyncContainer(consumablesData, targetId);
    CheckAndSyncContainer(equipmentData, targetId);
}

private void CheckAndSyncContainer(InventoryData container, string targetId)
{
    foreach (var slot in container.slots)
    {
        if (slot.itemData != null && slot.itemData.itemName == targetId)
        {
            QuestRegistry.Instance.AdvanceObjective(targetId, slot.itemAmount);
        }
    }
}

public void DeductQuestItems(InventoryItem costItem, int amountToDeduct)
{
    DeductFromContainer(costItem, ref amountToDeduct, consumablesData);
    if (amountToDeduct > 0) DeductFromContainer(costItem, ref amountToDeduct, actionSlotsData);
    if (amountToDeduct > 0) DeductFromContainer(costItem, ref amountToDeduct, equipmentData);
    
    RefreshUIContainers();
}

private void DeductFromContainer(InventoryItem target, ref int remaining, InventoryData container)
{
    foreach (var slot in container.slots.Where(s => s.itemData == target.itemData))
    {
        if (slot.itemAmount >= remaining)
        {
            slot.itemAmount -= remaining;
            remaining = 0;
            break;
        }
        remaining -= slot.itemAmount;
        slot.itemAmount = 0;
    }
}

Comportamento Dinâmico do NPC

O QuestDispatcher controla o fluxo de diálogo do NPC. Em vez de verificar o estado da missão no Update (o que é ineficiente), utilizamos um método de atualização acionado apenas quando o diálogo é iniciado ou quando um evento de mudança de estado de missão ocorre.

[RequireComponent(typeof(DialogueController))]
public class QuestDispatcher : MonoBehaviour
{
   private DialogueController dialogueController;
   [SerializeField] private QuestProfile linkedQuest;

   [Header("Fluxos de Diálogo")] 
   [SerializeField] private DialogueData initialOffer;
   [SerializeField] private DialogueData inProgress;
   [SerializeField] private DialogueData readyToTurnIn;
   [SerializeField] private DialogueData completed;

   private void Awake() => dialogueController = GetComponent<DialogueController>();

   private void OnEnable() => QuestRegistry.Instance.OnQuestStateChanged += SyncDialogueContext;
   private void OnDisable() => QuestRegistry.Instance.OnQuestStateChanged -= SyncDialogueContext;

   public void TriggerDialogue()
   {
       SyncDialogueContext();
       dialogueController.StartDialogue();
   }

   private void SyncDialogueContext()
   {
       QuestProfile runtimeQuest = QuestRegistry.Instance.FetchQuest(linkedQuest);
       QuestStatus status = runtimeQuest != null ? runtimeQuest.CurrentStatus : QuestStatus.NotStarted;

       dialogueController.activeDialogueData = status switch
       {
           QuestStatus.NotStarted => initialOffer,
           QuestStatus.InProgress => inProgress,
           QuestStatus.ReadyToTurnIn => readyToTurnIn,
           QuestStatus.TurnedIn => completed,
           _ => initialOffer
       };
   }
}

Tags: Unity3D CSharp scriptableobjects game-architecture quest-system

Publicado em 7-17 08:45