Create a simple dialogue authoring system, tied into our items (#10)

- 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
This commit is contained in:
2025-09-29 09:34:15 +00:00
parent 2cd791f69d
commit f686f28cb8
73 changed files with 6530 additions and 173 deletions

View File

@@ -0,0 +1,627 @@
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;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 25bbad45f1fa4183b30ad76c62256fd6
timeCreated: 1758891211

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Dialogue
{
[Serializable]
public enum RuntimeDialogueNodeType
{
Dialogue,
WaitOnPuzzleStep,
WaitOnPickup,
WaitOnSlot,
WaitOnCombination,
End
}
[Serializable]
public class RuntimeDialogueGraph : ScriptableObject
{
public string entryNodeID;
public string speakerName;
public List<RuntimeDialogueNode> allNodes = new List<RuntimeDialogueNode>();
// Helper method to find a node by ID
public RuntimeDialogueNode GetNodeByID(string id)
{
return allNodes.Find(n => n.nodeID == id);
}
}
[Serializable]
public class RuntimeDialogueNode
{
public string nodeID;
public RuntimeDialogueNodeType nodeType;
public string nextNodeID;
// Basic dialogue
public List<string> dialogueLines = new List<string>();
public bool loopThroughLines;
// Conditional nodes
public string puzzleStepID; // For WaitOnPuzzleStep
public string pickupItemID; // For WaitOnPickup
public string slotItemID; // For WaitOnSlot
public string combinationResultItemID; // For WaitOnCombination
// For WaitOnSlot - different responses
public List<string> incorrectItemLines = new List<string>();
public bool loopThroughIncorrectLines;
public List<string> forbiddenItemLines = new List<string>();
public bool loopThroughForbiddenLines;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3c3be3596532450a923c31dfe0ed4aa9
timeCreated: 1758871423

View File

@@ -0,0 +1,263 @@
using System;
using System.Collections;
using TMPro;
using UnityEngine;
namespace Dialogue
{
/// <summary>
/// Display mode for the speech bubble text
/// </summary>
public enum TextDisplayMode
{
Instant, // Display all text at once
Typewriter // Display text one character at a time
}
[AddComponentMenu("Apple Hills/Dialogue/Speech Bubble")]
public class SpeechBubble : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI textDisplay;
[SerializeField] private TextDisplayMode displayMode = TextDisplayMode.Typewriter;
[SerializeField] private float typewriterSpeed = 0.05f; // Time between characters in seconds
[SerializeField] private AudioSource typingSoundSource;
[SerializeField] private float typingSoundFrequency = 3; // Play sound every X characters
[SerializeField] private bool useRichText = true; // Whether to respect rich text tags
[SerializeField] private float dialogueDisplayTime = 1.5f; // Time in seconds to display dialogue before showing prompt
[SerializeField] private string dialoguePromptText = ". . ."; // Text to show as a prompt for available dialogue
private Coroutine typewriterCoroutine;
private Coroutine promptUpdateCoroutine;
private string currentFullText = string.Empty;
private bool isVisible = false;
private void Awake()
{
}
/// <summary>
/// Show the speech bubble
/// </summary>
public void Show()
{
gameObject.SetActive(true);
isVisible = true;
}
/// <summary>
/// Hide the speech bubble
/// </summary>
public void Hide()
{
gameObject.SetActive(false);
isVisible = false;
// Stop any ongoing typewriter effect
if (typewriterCoroutine != null)
{
StopCoroutine(typewriterCoroutine);
typewriterCoroutine = null;
}
// Stop any prompt update coroutine
if (promptUpdateCoroutine != null)
{
StopCoroutine(promptUpdateCoroutine);
promptUpdateCoroutine = null;
}
}
/// <summary>
/// Toggle visibility of the speech bubble
/// </summary>
public void Toggle()
{
if (isVisible)
Hide();
else
Show();
}
/// <summary>
/// Set the text to display in the speech bubble
/// </summary>
/// <param name="text">Text to display</param>
public void SetText(string text)
{
if (textDisplay == null)
{
Debug.LogError("SpeechBubble: TextMeshProUGUI component is not assigned!");
return;
}
currentFullText = text;
// Stop any existing typewriter effect
if (typewriterCoroutine != null)
{
StopCoroutine(typewriterCoroutine);
typewriterCoroutine = null;
}
// Display text based on the selected mode
if (displayMode == TextDisplayMode.Instant)
{
textDisplay.text = text;
}
else // Typewriter mode
{
textDisplay.text = string.Empty; // Clear the text initially
typewriterCoroutine = StartCoroutine(TypewriterEffect(text));
}
// Make sure the bubble is visible when setting text
if (!isVisible)
Show();
}
/// <summary>
/// Display a dialogue line and handle prompt visibility afterward
/// </summary>
/// <param name="line">The dialogue line to display</param>
/// <param name="hasMoreDialogue">Whether there are more dialogue lines available</param>
public void DisplayDialogueLine(string line, bool hasMoreDialogue)
{
// Cancel any existing prompt update
if (promptUpdateCoroutine != null)
{
StopCoroutine(promptUpdateCoroutine);
promptUpdateCoroutine = null;
}
// Display the dialogue line
if (!string.IsNullOrEmpty(line))
{
SetText(line);
// After a delay, update the prompt visibility
promptUpdateCoroutine = StartCoroutine(UpdatePromptAfterDelay(hasMoreDialogue));
}
else
{
// If no line to display, update prompt visibility immediately
UpdatePromptVisibility(hasMoreDialogue);
}
}
/// <summary>
/// Update the speech bubble to either show a prompt or hide based on dialogue availability
/// </summary>
/// <param name="hasDialogueAvailable">Whether dialogue is available</param>
public void UpdatePromptVisibility(bool hasDialogueAvailable)
{
if (hasDialogueAvailable)
{
Show();
SetText(dialoguePromptText);
}
else
{
Hide();
}
}
/// <summary>
/// Coroutine to update the prompt visibility after a delay
/// </summary>
private IEnumerator UpdatePromptAfterDelay(bool hasMoreDialogue)
{
// Wait for the configured display time
yield return new WaitForSeconds(dialogueDisplayTime);
// Update the prompt visibility
UpdatePromptVisibility(hasMoreDialogue);
promptUpdateCoroutine = null;
}
/// <summary>
/// Change the display mode
/// </summary>
/// <param name="mode">New display mode</param>
public void SetDisplayMode(TextDisplayMode mode)
{
displayMode = mode;
// If we're changing modes while text is displayed, refresh it
if (!string.IsNullOrEmpty(currentFullText))
{
SetText(currentFullText);
}
}
/// <summary>
/// Skip the typewriter effect and show the full text immediately
/// </summary>
public void SkipTypewriter()
{
if (typewriterCoroutine != null)
{
StopCoroutine(typewriterCoroutine);
typewriterCoroutine = null;
textDisplay.text = currentFullText;
}
}
/// <summary>
/// Set the speed of the typewriter effect
/// </summary>
/// <param name="charactersPerSecond">Characters per second</param>
public void SetTypewriterSpeed(float charactersPerSecond)
{
if (charactersPerSecond <= 0)
{
Debug.LogError("SpeechBubble: Typewriter speed must be greater than 0!");
return;
}
typewriterSpeed = 1f / charactersPerSecond;
}
/// <summary>
/// Coroutine that gradually reveals text one character at a time
/// </summary>
private IEnumerator TypewriterEffect(string text)
{
int visibleCount = 0;
int characterCount = 0;
while (visibleCount < text.Length)
{
// Skip rich text tags if enabled
if (useRichText && visibleCount < text.Length && text[visibleCount] == '<')
{
// Find the end of the tag
int tagEnd = text.IndexOf('>', visibleCount);
if (tagEnd != -1)
{
// Include the entire tag at once
visibleCount = tagEnd + 1;
textDisplay.text = text.Substring(0, visibleCount);
continue;
}
}
// Reveal the next character
visibleCount++;
characterCount++;
textDisplay.text = text.Substring(0, visibleCount);
// Play typing sound at specified frequency
if (typingSoundSource != null && characterCount % typingSoundFrequency == 0)
{
typingSoundSource.Play();
}
yield return new WaitForSeconds(typewriterSpeed);
}
typewriterCoroutine = null;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cb3605ae81a54d2689504e0cd456ac27
timeCreated: 1758973942

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 877344c7a0014922bc3a2a469e03792d
timeCreated: 1759050622