- Editor dialogue graph - Asset importer for processing the graph into runtime data - DialogueComponent that steers the dialogue interactions - DialogueCanbas with a scalable speech bubble to display everything - Brief README overview of the system Co-authored-by: AlexanderT <alexander@foolhardyhorizons.com> Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com> Reviewed-on: #10
628 lines
25 KiB
C#
628 lines
25 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using Core;
|
|
using Interactions;
|
|
using UnityEngine;
|
|
using PuzzleS;
|
|
|
|
namespace Dialogue
|
|
{
|
|
[AddComponentMenu("Apple Hills/Dialogue/Dialogue Component")]
|
|
public class DialogueComponent : MonoBehaviour
|
|
{
|
|
[SerializeField] private RuntimeDialogueGraph dialogueGraph;
|
|
|
|
private RuntimeDialogueNode currentNode;
|
|
private int currentLineIndex;
|
|
private bool initialized = false;
|
|
private SpeechBubble speechBubble;
|
|
|
|
// Flag to track when a condition has been met but dialogue hasn't advanced yet
|
|
private bool _conditionSatisfiedPendingAdvance = false;
|
|
|
|
// Track the current slot state for WaitOnSlot nodes
|
|
private ItemSlotState _currentSlotState = ItemSlotState.None;
|
|
private PickupItemData _lastSlottedItem;
|
|
|
|
// Properties
|
|
public bool IsActive { get; private set; }
|
|
public bool IsCompleted { get; private set; }
|
|
public string CurrentSpeakerName => dialogueGraph?.speakerName;
|
|
|
|
// Event for UI updates if needed
|
|
public event Action<string> OnDialogueChanged;
|
|
|
|
private void Start()
|
|
{
|
|
// Register for global events
|
|
if (PuzzleManager.Instance != null)
|
|
PuzzleManager.Instance.OnStepCompleted += OnAnyPuzzleStepCompleted;
|
|
|
|
if (ItemManager.Instance != null)
|
|
{
|
|
ItemManager.Instance.OnItemPickedUp += OnAnyItemPickedUp;
|
|
ItemManager.Instance.OnCorrectItemSlotted += OnAnyItemSlotted;
|
|
ItemManager.Instance.OnIncorrectItemSlotted += OnAnyIncorrectItemSlotted;
|
|
ItemManager.Instance.OnForbiddenItemSlotted += OnAnyForbiddenItemSlotted;
|
|
ItemManager.Instance.OnItemSlotCleared += OnAnyItemSlotCleared;
|
|
ItemManager.Instance.OnItemsCombined += OnAnyItemsCombined;
|
|
}
|
|
|
|
speechBubble = GetComponentInChildren<SpeechBubble>();
|
|
|
|
if (speechBubble == null)
|
|
{
|
|
Debug.LogError("SpeechBubble component is missing on Dialogue Component");
|
|
}
|
|
|
|
// Auto-start the dialogue
|
|
// StartDialogue();
|
|
|
|
var interactable = GetComponent<Interactable>();
|
|
if (interactable != null)
|
|
{
|
|
interactable.characterArrived.AddListener(OnCharacterArrived);
|
|
}
|
|
|
|
// Update bubble visibility based on whether we have lines
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
|
|
private void OnCharacterArrived()
|
|
{
|
|
if (speechBubble == null || ! HasAnyLines()) return;
|
|
|
|
AdvanceDialogueState();
|
|
|
|
// Get the current dialogue line
|
|
string line = GetCurrentDialogueLine();
|
|
|
|
// Display the line with the new method that handles timed updates
|
|
speechBubble.DisplayDialogueLine(line, HasAnyLines());
|
|
|
|
// Advance dialogue state for next interaction
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// Unregister from events
|
|
if (PuzzleManager.Instance != null)
|
|
PuzzleManager.Instance.OnStepCompleted -= OnAnyPuzzleStepCompleted;
|
|
|
|
if (ItemManager.Instance != null)
|
|
{
|
|
ItemManager.Instance.OnItemPickedUp -= OnAnyItemPickedUp;
|
|
ItemManager.Instance.OnCorrectItemSlotted -= OnAnyItemSlotted;
|
|
ItemManager.Instance.OnIncorrectItemSlotted -= OnAnyIncorrectItemSlotted;
|
|
ItemManager.Instance.OnForbiddenItemSlotted -= OnAnyForbiddenItemSlotted;
|
|
ItemManager.Instance.OnItemSlotCleared -= OnAnyItemSlotCleared;
|
|
ItemManager.Instance.OnItemsCombined -= OnAnyItemsCombined;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start the dialogue from the beginning
|
|
/// </summary>
|
|
public void StartDialogue()
|
|
{
|
|
if (dialogueGraph == null)
|
|
{
|
|
Debug.LogError("DialogueComponent: No dialogue graph assigned!");
|
|
return;
|
|
}
|
|
|
|
// Reset state
|
|
IsActive = true;
|
|
IsCompleted = false;
|
|
currentLineIndex = 0;
|
|
initialized = true;
|
|
|
|
// Set to entry node
|
|
currentNode = dialogueGraph.GetNodeByID(dialogueGraph.entryNodeID);
|
|
|
|
// Process the node
|
|
ProcessCurrentNode();
|
|
|
|
// Update bubble visibility based on whether we have lines
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the current dialogue line and advance to the next line or node if appropriate
|
|
/// Each call represents one interaction with the NPC
|
|
/// </summary>
|
|
public string GetCurrentDialogueLine()
|
|
{
|
|
// Initialize if needed
|
|
if (!initialized)
|
|
{
|
|
StartDialogue();
|
|
}
|
|
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return string.Empty;
|
|
|
|
// For WaitOnSlot nodes, use the appropriate line type based on slot state
|
|
if (currentNode.nodeType == RuntimeDialogueNodeType.WaitOnSlot)
|
|
{
|
|
// Choose the appropriate line collection based on the current slot state
|
|
List<string> linesForState = currentNode.dialogueLines; // Default lines
|
|
|
|
switch (_currentSlotState)
|
|
{
|
|
case ItemSlotState.Incorrect:
|
|
// Use incorrect item lines if available, otherwise fall back to default lines
|
|
if (currentNode.incorrectItemLines != null && currentNode.incorrectItemLines.Count > 0)
|
|
linesForState = currentNode.incorrectItemLines;
|
|
break;
|
|
|
|
case ItemSlotState.Forbidden:
|
|
// Use forbidden item lines if available, otherwise fall back to default lines
|
|
if (currentNode.forbiddenItemLines != null && currentNode.forbiddenItemLines.Count > 0)
|
|
linesForState = currentNode.forbiddenItemLines;
|
|
break;
|
|
|
|
// For None or Correct state, use the default lines
|
|
default:
|
|
linesForState = currentNode.dialogueLines;
|
|
break;
|
|
}
|
|
|
|
// If we have lines for this state, return the current one
|
|
if (linesForState != null && linesForState.Count > 0)
|
|
{
|
|
// Make sure index is within bounds
|
|
int index = Mathf.Clamp(currentLineIndex, 0, linesForState.Count - 1);
|
|
return linesForState[index];
|
|
}
|
|
}
|
|
|
|
// For other node types, use the default dialogueLines
|
|
if (currentNode.dialogueLines == null || currentNode.dialogueLines.Count == 0)
|
|
return string.Empty;
|
|
|
|
// Get current line
|
|
string currentLine = string.Empty;
|
|
if (currentLineIndex >= 0 && currentLineIndex < currentNode.dialogueLines.Count)
|
|
{
|
|
currentLine = currentNode.dialogueLines[currentLineIndex];
|
|
}
|
|
|
|
return currentLine;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advance dialogue state for the next interaction
|
|
/// </summary>
|
|
private void AdvanceDialogueState()
|
|
{
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return;
|
|
|
|
// If the condition was satisfied earlier, move to the next node immediately
|
|
if (_conditionSatisfiedPendingAdvance)
|
|
{
|
|
_conditionSatisfiedPendingAdvance = false; // Reset flag
|
|
MoveToNextNode();
|
|
return;
|
|
}
|
|
|
|
// If we have more lines in the current node, advance to the next line
|
|
if (currentLineIndex < currentNode.dialogueLines.Count - 1)
|
|
{
|
|
currentLineIndex++;
|
|
return;
|
|
}
|
|
|
|
// If we should loop through lines, reset the index
|
|
if (currentNode.loopThroughLines && currentNode.dialogueLines.Count > 0)
|
|
{
|
|
currentLineIndex = 0;
|
|
return;
|
|
}
|
|
|
|
// If we're at a node that doesn't have a next node, we're done
|
|
if (string.IsNullOrEmpty(currentNode.nextNodeID))
|
|
{
|
|
IsActive = false;
|
|
IsCompleted = true;
|
|
return;
|
|
}
|
|
|
|
// Move to the next node only if no conditions to wait for
|
|
if (!IsWaitingForCondition())
|
|
{
|
|
MoveToNextNode();
|
|
}
|
|
}
|
|
|
|
private void MoveToNextNode()
|
|
{
|
|
Debug.Log("MoveToNextNode");
|
|
|
|
// If there's no next node, complete the dialogue
|
|
if (string.IsNullOrEmpty(currentNode.nextNodeID))
|
|
{
|
|
IsActive = false;
|
|
IsCompleted = true;
|
|
return;
|
|
}
|
|
|
|
// Move to the next node
|
|
currentNode = dialogueGraph.GetNodeByID(currentNode.nextNodeID);
|
|
currentLineIndex = 0;
|
|
|
|
// Process the new node
|
|
ProcessCurrentNode();
|
|
}
|
|
|
|
private void ProcessCurrentNode()
|
|
{
|
|
if (currentNode == null)
|
|
{
|
|
Debug.LogError("DialogueComponent: Current node is null!");
|
|
return;
|
|
}
|
|
|
|
// Handle different node types
|
|
switch (currentNode.nodeType)
|
|
{
|
|
case RuntimeDialogueNodeType.Dialogue:
|
|
// Regular dialogue node, nothing special to do
|
|
break;
|
|
|
|
case RuntimeDialogueNodeType.WaitOnPuzzleStep:
|
|
// Check if the puzzle step is already completed
|
|
if (IsPuzzleStepComplete(currentNode.puzzleStepID))
|
|
{
|
|
// If it's already complete, move past this node automatically
|
|
MoveToNextNode();
|
|
}
|
|
break;
|
|
|
|
case RuntimeDialogueNodeType.WaitOnPickup:
|
|
// Check if the item is already picked up
|
|
if (IsItemPickedUp(currentNode.pickupItemID))
|
|
{
|
|
// If it's already picked up, move past this node automatically
|
|
MoveToNextNode();
|
|
}
|
|
break;
|
|
|
|
case RuntimeDialogueNodeType.WaitOnSlot:
|
|
// Check if the item is already slotted
|
|
if (IsItemSlotted(currentNode.slotItemID))
|
|
{
|
|
// If it's already slotted, move past this node automatically
|
|
MoveToNextNode();
|
|
}
|
|
break;
|
|
|
|
case RuntimeDialogueNodeType.WaitOnCombination:
|
|
// Check if the result item is already created through combination
|
|
if (IsResultItemCreated(currentNode.combinationResultItemID))
|
|
{
|
|
// If it's already created, move past this node automatically
|
|
MoveToNextNode();
|
|
}
|
|
break;
|
|
|
|
case RuntimeDialogueNodeType.End:
|
|
// End node, complete the dialogue
|
|
IsActive = false;
|
|
IsCompleted = true;
|
|
break;
|
|
|
|
default:
|
|
Debug.LogError($"DialogueComponent: Unknown node type {currentNode.nodeType}");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Global event handlers
|
|
private void OnAnyPuzzleStepCompleted(PuzzleStepSO step)
|
|
{
|
|
// Only react if we're active and waiting on a puzzle step
|
|
if (!IsActive || IsCompleted || currentNode == null ||
|
|
currentNode.nodeType != RuntimeDialogueNodeType.WaitOnPuzzleStep)
|
|
return;
|
|
|
|
// Check if this is the step we're waiting for
|
|
if (step.stepId == currentNode.puzzleStepID)
|
|
{
|
|
// Instead of immediately moving to the next node, set the flag
|
|
_conditionSatisfiedPendingAdvance = true;
|
|
|
|
// Update bubble visibility after state change to show interaction prompt
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyItemPickedUp(PickupItemData item)
|
|
{
|
|
// Only react if we're active and waiting on an item pickup
|
|
if (!IsActive || IsCompleted || currentNode == null ||
|
|
currentNode.nodeType != RuntimeDialogueNodeType.WaitOnPickup)
|
|
return;
|
|
|
|
// Check if this is the item we're waiting for
|
|
if (item.itemId == currentNode.pickupItemID)
|
|
{
|
|
// Instead of immediately moving to the next node, set the flag
|
|
_conditionSatisfiedPendingAdvance = true;
|
|
|
|
// Update bubble visibility after state change to show interaction prompt
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyItemSlotted(PickupItemData slotDefinition, PickupItemData slottedItem)
|
|
{
|
|
Debug.Log("[DialogueComponent] OnAnyItemSlotted");
|
|
|
|
// Only react if we're active and waiting on a slot
|
|
if (!IsActive || IsCompleted || currentNode == null ||
|
|
currentNode.nodeType != RuntimeDialogueNodeType.WaitOnSlot)
|
|
return;
|
|
|
|
// Check if this is the slot we're waiting for
|
|
if (slotDefinition.itemId == currentNode.slotItemID)
|
|
{
|
|
// Instead of immediately moving to the next node, set the flag
|
|
_conditionSatisfiedPendingAdvance = true;
|
|
|
|
// Update bubble visibility after state change to show interaction prompt
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyItemsCombined(PickupItemData itemA, PickupItemData itemB, PickupItemData resultItem)
|
|
{
|
|
// Only react if we're active and waiting on a combination
|
|
if (!IsActive || IsCompleted || currentNode == null ||
|
|
currentNode.nodeType != RuntimeDialogueNodeType.WaitOnCombination)
|
|
return;
|
|
|
|
// Check if this is the result item we're waiting for
|
|
if (resultItem.itemId == currentNode.combinationResultItemID)
|
|
{
|
|
// Instead of immediately moving to the next node, set the flag
|
|
_conditionSatisfiedPendingAdvance = true;
|
|
|
|
// Update bubble visibility after state change to show interaction prompt
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyIncorrectItemSlotted(PickupItemData slotDefinition, PickupItemData slottedItem)
|
|
{
|
|
// Update the slot state for displaying the correct dialogue lines
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return;
|
|
|
|
// Only update state if we're actively waiting on this slot
|
|
if (currentNode.nodeType == RuntimeDialogueNodeType.WaitOnSlot &&
|
|
slotDefinition.itemId == currentNode.slotItemID)
|
|
{
|
|
_currentSlotState = ItemSlotState.Incorrect;
|
|
_lastSlottedItem = slottedItem;
|
|
|
|
// Trigger dialogue update
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyForbiddenItemSlotted(PickupItemData slotDefinition, PickupItemData slottedItem)
|
|
{
|
|
// Update the slot state for displaying the correct dialogue lines
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return;
|
|
|
|
// Only update state if we're actively waiting on this slot
|
|
if (currentNode.nodeType == RuntimeDialogueNodeType.WaitOnSlot &&
|
|
slotDefinition.itemId == currentNode.slotItemID)
|
|
{
|
|
_currentSlotState = ItemSlotState.Forbidden;
|
|
_lastSlottedItem = slottedItem;
|
|
|
|
// Trigger dialogue update
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnAnyItemSlotCleared(PickupItemData removedItem)
|
|
{
|
|
// Update the slot state when an item is removed
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return;
|
|
|
|
// Reset slot state if we were tracking this item
|
|
if (_lastSlottedItem != null && _lastSlottedItem == removedItem)
|
|
{
|
|
_currentSlotState = ItemSlotState.None;
|
|
_lastSlottedItem = null;
|
|
|
|
// Trigger dialogue update
|
|
if (speechBubble != null)
|
|
{
|
|
speechBubble.UpdatePromptVisibility(HasAnyLines());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper methods
|
|
private bool IsWaitingForCondition()
|
|
{
|
|
if (currentNode == null) return false;
|
|
|
|
switch (currentNode.nodeType)
|
|
{
|
|
case RuntimeDialogueNodeType.WaitOnPuzzleStep:
|
|
return !IsPuzzleStepComplete(currentNode.puzzleStepID);
|
|
case RuntimeDialogueNodeType.WaitOnPickup:
|
|
return !IsItemPickedUp(currentNode.pickupItemID);
|
|
case RuntimeDialogueNodeType.WaitOnSlot:
|
|
return !IsItemSlotted(currentNode.slotItemID);
|
|
case RuntimeDialogueNodeType.WaitOnCombination:
|
|
return !IsResultItemCreated(currentNode.combinationResultItemID);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool IsPuzzleStepComplete(string stepID)
|
|
{
|
|
return PuzzleManager.Instance != null &&
|
|
PuzzleManager.Instance.IsPuzzleStepCompleted(stepID);
|
|
}
|
|
|
|
private bool IsItemPickedUp(string itemID)
|
|
{
|
|
if (ItemManager.Instance == null) return false;
|
|
|
|
// Check all pickups for the given ID
|
|
foreach (var pickup in ItemManager.Instance.Pickups)
|
|
{
|
|
if (pickup.isPickedUp && pickup.itemData != null &&
|
|
pickup.itemData.itemId == itemID)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private bool IsItemSlotted(string slotID)
|
|
{
|
|
if (ItemManager.Instance == null) return false;
|
|
|
|
// Check if any slot with this ID has the correct item
|
|
foreach (var slot in ItemManager.Instance.ItemSlots)
|
|
{
|
|
if (slot.itemData != null && slot.itemData.itemId == slotID &&
|
|
slot.CurrentSlottedState == ItemSlotState.Correct)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private bool IsResultItemCreated(string resultItemId)
|
|
{
|
|
if (ItemManager.Instance == null) return false;
|
|
|
|
// Use the ItemManager's tracking of items created through combination
|
|
return ItemManager.Instance.WasItemCreatedThroughCombination(resultItemId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the dialogue component has any lines available to serve
|
|
/// </summary>
|
|
/// <returns>True if there are lines available, false otherwise</returns>
|
|
public bool HasAnyLines()
|
|
{
|
|
if (!initialized)
|
|
{
|
|
// If not initialized yet but has a dialogue graph, it will have lines when initialized
|
|
return dialogueGraph != null;
|
|
}
|
|
|
|
// No lines if dialogue is not active or is completed
|
|
if (!IsActive || IsCompleted || currentNode == null)
|
|
return false;
|
|
|
|
// Special case: if condition has been satisfied but not yet advanced, we should show lines
|
|
if (_conditionSatisfiedPendingAdvance && !string.IsNullOrEmpty(currentNode.nextNodeID))
|
|
{
|
|
// Check if the next node would have lines
|
|
RuntimeDialogueNode nextNode = dialogueGraph.GetNodeByID(currentNode.nextNodeID);
|
|
return nextNode != null && (nextNode.dialogueLines.Count > 0 || nextNode.nodeType != RuntimeDialogueNodeType.End);
|
|
}
|
|
|
|
// For WaitOnSlot nodes, check for lines based on current slot state
|
|
if (currentNode.nodeType == RuntimeDialogueNodeType.WaitOnSlot)
|
|
{
|
|
// Choose the appropriate line collection based on the current slot state
|
|
List<string> linesForState = currentNode.dialogueLines; // Default lines
|
|
|
|
switch (_currentSlotState)
|
|
{
|
|
case ItemSlotState.Incorrect:
|
|
// Use incorrect item lines if available, otherwise fall back to default lines
|
|
if (currentNode.incorrectItemLines != null && currentNode.incorrectItemLines.Count > 0)
|
|
linesForState = currentNode.incorrectItemLines;
|
|
break;
|
|
|
|
case ItemSlotState.Forbidden:
|
|
// Use forbidden item lines if available, otherwise fall back to default lines
|
|
if (currentNode.forbiddenItemLines != null && currentNode.forbiddenItemLines.Count > 0)
|
|
linesForState = currentNode.forbiddenItemLines;
|
|
break;
|
|
}
|
|
|
|
// Check if we have any lines for the current state
|
|
if (linesForState != null && linesForState.Count > 0)
|
|
{
|
|
// If we're not at the end of the lines or we loop through them
|
|
if (currentLineIndex < linesForState.Count - 1 || currentNode.loopThroughLines)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
// For other node types, use the standard check
|
|
else if (currentNode.dialogueLines.Count > 0)
|
|
{
|
|
// If we're not at the end of the lines or we loop through them
|
|
if (currentLineIndex < currentNode.dialogueLines.Count - 1 || currentNode.loopThroughLines)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// If we're at the end of lines but not waiting for a condition and have a next node
|
|
if (!IsWaitingForCondition() && !string.IsNullOrEmpty(currentNode.nextNodeID))
|
|
{
|
|
// We need to check if the next node would have lines
|
|
RuntimeDialogueNode nextNode = dialogueGraph.GetNodeByID(currentNode.nextNodeID);
|
|
return nextNode != null && (nextNode.dialogueLines.Count > 0 || nextNode.nodeType != RuntimeDialogueNodeType.End);
|
|
}
|
|
}
|
|
|
|
|
|
return false;
|
|
}
|
|
|
|
// Editor functionality
|
|
public void SetDialogueGraph(RuntimeDialogueGraph graph)
|
|
{
|
|
dialogueGraph = graph;
|
|
}
|
|
}
|
|
}
|