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