chore: Project Restructuring & Theatre

This commit is contained in:
Alexandre Coirier
2026-05-13 12:24:48 +02:00
parent 4070916c1f
commit e96d0bf6e6
2145 changed files with 20957 additions and 44792 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d2498a28ae6dbe4e98260ebaf559261
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,182 @@
using UnityEngine;
namespace Unheimlich.Features.Dialogue.Data
{
/// <summary>
/// ScriptableObject configuration for dialogue choice spawn behavior and animations.
/// Defines spawn patterns, floating effects, rotation, distance scaling, and focus behavior.
/// </summary>
[CreateAssetMenu(fileName = "DialogueAnimationSettings", menuName = "Unheimlich/Animation/Dialogue Animation Settings")]
public class DialogueAnimationSettings : ScriptableObject
{
[Header("Choice Spawn Animation")]
[Tooltip("How choices appear in the world")]
public ChoiceSpawnMode SpawnMode = ChoiceSpawnMode.AroundAnchor;
[Tooltip("Default scale multiplier for spawned choices (1.0 = normal size)")]
public float DefaultScaleMultiplier = 1.0f;
[Tooltip("Minimum scale variation multiplier (e.g., 0.5 = 50% of base scale)")]
[Range(0.1f, 2f)]
public float MinScaleVariation = 0.5f;
[Tooltip("Maximum scale variation multiplier (e.g., 1.5 = 150% of base scale)")]
[Range(0.1f, 2f)]
public float MaxScaleVariation = 1.5f;
[Tooltip("Enable random scale variation for each word")]
public bool EnableScaleVariation = false;
[Header("Spawn Settings")]
[Tooltip("Radius around anchor where choices spawn")]
public float SpawnRadius = 2f;
[Tooltip("Height offset from anchor")]
public float SpawnHeightOffset = 0f;
[Tooltip("Should choices face the player?")]
public bool FacePlayer = true;
[Header("Animation")]
[Tooltip("Duration of spawn animation")]
public float SpawnAnimationDuration = 0.5f;
[Tooltip("Spawn animation curve")]
public AnimationCurve SpawnCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[Header("Idle Behavior")]
[Tooltip("Should choices float vertically?")]
public bool EnableFloating = true;
[Tooltip("Floating amplitude")]
public float FloatingAmplitude = 0.2f;
[Tooltip("Floating duration (full cycle)")]
public float FloatingDuration = 3f;
[Header("Organic Shake")]
[Tooltip("Enable organic shake/drift movement around center point (Heavy Rain style)")]
public bool EnableOrganicShake = false;
[Tooltip("Radius around center point where choices can drift")]
public float ShakeRadius = 0.15f;
[Tooltip("Speed of the shake movement")]
public float ShakeSpeed = 1.5f;
[Tooltip("Smoothness of shake transitions (higher = smoother)")]
public float ShakeSmoothness = 2f;
[Tooltip("Shake speed multiplier for unfocused choices when one is focused (0.3 = 30% speed)")]
[Range(0f, 1f)]
public float UnfocusedShakeSpeedMultiplier = 0.3f;
[Header("Ghost Trail Effect")]
[Tooltip("Enable ghost/shadow trail effect behind text (Heavy Rain style)")]
public bool EnableGhostTrail = false;
[Tooltip("Time delay for ghost trail in seconds (how far behind the ghost follows)")]
public float GhostTrailDelay = 0.15f;
[Tooltip("Transparency of the ghost trail (0 = invisible, 1 = opaque)")]
[Range(0f, 1f)]
public float GhostTrailAlpha = 0.3f;
[Tooltip("Should container rotate continuously?")]
public bool EnableRotation = true;
[Tooltip("Axis of rotation (Forward = Z, Up = Y, Right = X)")]
public RotationAxis RotationAxis = RotationAxis.Forward;
[Tooltip("Normal rotation speed when not focused (degrees per second). Higher = faster rotation.")]
public float NormalRotationSpeed = 36f;
[Header("Follow Player")]
[Tooltip("Should container follow player position?")]
public bool FollowPlayerPosition = false;
[Tooltip("Should container follow player Y rotation? (keeps choices in front of player)")]
public bool FollowPlayerRotation = false;
[Header("Distance-Based Scaling")]
[Tooltip("Should scale and radius adjust based on distance to player?")]
public bool EnableDistanceScaling = false;
[Tooltip("Minimum distance for scaling calculation")]
public float MinDistance = 2f;
[Tooltip("Maximum distance for scaling calculation")]
public float MaxDistance = 10f;
[Tooltip("Scale multiplier at minimum distance")]
public float MinScaleMultiplier = 1f;
[Tooltip("Scale multiplier at maximum distance")]
public float MaxScaleMultiplier = 3f;
[Header("Focus Animation")]
[Tooltip("Should choices move toward player when focused?")]
public bool MoveTowardPlayerOnFocus = true;
[Tooltip("Distance from player when focused")]
public float FocusDistanceFromPlayer = 1.5f;
[Tooltip("Rotation speed when a choice is focused (degrees per second). Higher = faster rotation.")]
public float FocusRotationSpeed = 60f;
[Tooltip("Enable camera shake when focusing on a choice")]
public bool EnableCameraShakeOnFocus = true;
[Tooltip("Camera shake intensity when focusing (higher = less shake, lower = more shake)")]
public float CameraShakeIntensity = 30f;
[Tooltip("Camera shake smoothness (0 = instant/jarring, higher = smoother). Controls how fast the camera moves to shake position.")]
public float CameraShakeSmoothness = 10f;
[Tooltip("Scale down multiplier for the FOCUSED choice text (0.9 = 90% size)")]
[Range(0f, 1f)]
public float FocusedTextScaleMultiplier = 0.9f;
[Tooltip("Duration of the text scale animation when focusing (in seconds)")]
public float FocusedTextScaleDuration = 0.3f;
[Tooltip("Duration of the text scale reset animation when unfocusing (in seconds)")]
public float UnfocusedTextScaleDuration = 0.3f;
[Header("Sketch & Fade on Selection")]
[Tooltip("Enable sketch and fade effect on non-selected words when a choice is focused")]
public bool EnableSketchFadeOnFocus = false;
[Tooltip("Material with sketch shader for TextMeshPro")]
public Material SketchMaterial;
[Tooltip("Duration of the sketch effect before fade (in seconds)")]
public float SketchDuration = 0.3f;
[Tooltip("Duration of the fade out after sketch (in seconds)")]
public float FadeOutDuration = 0.5f;
[Tooltip("Intensity of the sketch effect (0 = no sketch, 1 = full sketch)")]
[Range(0f, 1f)]
public float SketchIntensity = 0.8f;
[Tooltip("Final alpha value after fade (0 = invisible, 1 = opaque)")]
[Range(0f, 1f)]
public float FinalFadeAlpha = 0.2f;
}
public enum ChoiceSpawnMode
{
AroundAnchor,
AtAnchorPosition,
InFrontOfPlayer,
CircleAroundPlayer
}
public enum RotationAxis
{
Forward,
Up,
Right
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1aec9b5da9003c646be8289148a4a405
@@ -0,0 +1,40 @@
using UnityEngine;
using OctoCore.Localization;
namespace Unheimlich.Features.Dialogue.Data
{
[System.Serializable]
public class DialogueChoiceData
{
[Header("Choice Content")]
[Tooltip("Localized text displayed on the choice button")]
public OctoLocalizedTextData ChoiceText;
[HideInInspector]
public string ChoiceTextOverride;
[Header("Flow")]
[Tooltip("Direct reference to the next DialogueNode")]
public DialogueNode NextNode;
[Header("Conditions (Optional)")]
[Tooltip("Required condition to display this choice (optional, for future use)")]
public string RequiredCondition;
public string GetLocalizedChoiceText()
{
if (!string.IsNullOrEmpty(ChoiceTextOverride))
return ChoiceTextOverride;
if (ChoiceText != null)
return OctoLocalization.GetLocalizedText(ChoiceText);
return string.Empty;
}
public bool IsValid()
{
return ChoiceText != null && NextNode != null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6a3b0ea4c9c5bec419eac7026710bfc9
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using UnityEngine;
using OctoCore.Localization;
namespace Unheimlich.Features.Dialogue.Data
{
[CreateAssetMenu(fileName = "NewConversation", menuName = "Unheimlich/Dialogue/Conversation")]
public class DialogueConversation : ScriptableObject
{
[Header("Speaker Information")]
[Tooltip("Localized name of the speaker (common to all texts in this conversation)")]
public OctoLocalizedTextData SpeakerName;
[Header("Display Mode")]
[Tooltip("Choose how to display dialogue text: World (3D space) or Screen (Canvas UI)")]
public DialogueDisplayMode DisplayMode = DialogueDisplayMode.World;
[Header("Dialogue Flow")]
[Tooltip("Ordered list of dialogue nodes for this conversation")]
public List<DialogueNode> DialogueNodes = new List<DialogueNode>();
public string GetLocalizedSpeakerName()
{
if (SpeakerName != null)
return OctoLocalization.GetLocalizedText(SpeakerName);
return string.Empty;
}
public DialogueNode GetFirstNode()
{
return DialogueNodes != null && DialogueNodes.Count > 0 ? DialogueNodes[0] : null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83cc2feccc2da50418d37dfef21a2e99
@@ -0,0 +1,367 @@
using UnityEngine;
using System.Collections.Generic;
using OctoCore.Localization;
#if UNITY_EDITOR
using UnityEditor;
using System.IO;
#endif
namespace Unheimlich.Features.Dialogue.Data
{
public static class DialogueConversationBuilder
{
// Helper methods kept for compatibility but now create localized text data
}
#if UNITY_EDITOR
public class DialogueCreatorWindow : EditorWindow
{
private string _speakerName = "NPC";
private string _savePath = "Assets/Unheimlich_POC/Datas/DialogueData";
private string _conversationName = "NewConversation";
private List<NodeTemplate> _nodes = new List<NodeTemplate>();
private Vector2 _scrollPosition;
[System.Serializable]
private class NodeTemplate
{
public string NodeText = "Dialogue text";
public List<ChoiceTemplate> Choices = new List<ChoiceTemplate>
{
new ChoiceTemplate { Text = "Choice 1", TargetNodeIndex = 1 },
new ChoiceTemplate { Text = "Choice 2", TargetNodeIndex = 1 }
};
public bool IsEndNode = false;
public bool Foldout = true;
}
[System.Serializable]
private class ChoiceTemplate
{
public string Text = "New choice";
public int TargetNodeIndex = 1;
}
[MenuItem("Unheimlich/Tools/Dialogue/Dialogue Creator")]
public static void ShowWindow()
{
var window = GetWindow<DialogueCreatorWindow>("Dialogue Creator");
window.minSize = new Vector2(500, 600);
window.Show();
}
private void OnEnable()
{
if (_nodes.Count == 0)
{
_nodes.Add(new NodeTemplate
{
NodeText = "Hello! Starting dialogue",
Choices = new List<ChoiceTemplate>
{
new ChoiceTemplate { Text = "Option 1", TargetNodeIndex = 1 },
new ChoiceTemplate { Text = "Option 2", TargetNodeIndex = 1 }
}
});
}
}
private void OnGUI()
{
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Dialogue Creator", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("Quickly create a complete conversation with all nodes pre-linked!\nTexts will be created as localized assets (you can edit FR/EN later in Inspector).", MessageType.Info);
EditorGUILayout.Space(10);
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("General Configuration", EditorStyles.boldLabel);
_speakerName = EditorGUILayout.TextField("Speaker Name", _speakerName);
_conversationName = EditorGUILayout.TextField("Conversation Name", _conversationName);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Save Path", GUILayout.Width(150));
_savePath = EditorGUILayout.TextField(_savePath);
if (GUILayout.Button("...", GUILayout.Width(30)))
{
string newPath = EditorUtility.OpenFolderPanel("Choose Folder", _savePath, "");
if (!string.IsNullOrEmpty(newPath))
{
if (newPath.StartsWith(Application.dataPath))
_savePath = "Assets" + newPath.Substring(Application.dataPath.Length);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
EditorGUILayout.Space(10);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Dialogue Structure", EditorStyles.boldLabel);
if (GUILayout.Button("+ Add Node", GUILayout.Width(150)))
{
NodeTemplate newNode = new NodeTemplate
{
NodeText = $"Node {_nodes.Count + 1}",
Choices = new List<ChoiceTemplate>
{
new ChoiceTemplate
{
Text = "Choice 1",
TargetNodeIndex = Mathf.Min(_nodes.Count + 1, _nodes.Count)
}
}
};
_nodes.Add(newNode);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(5);
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
for (int i = 0; i < _nodes.Count; i++)
{
DrawNodeEditor(i);
}
EditorGUILayout.EndScrollView();
EditorGUILayout.Space(10);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Create Conversation", GUILayout.Height(40), GUILayout.Width(200)))
{
CreateDialogue();
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(10);
}
private void DrawNodeEditor(int index)
{
NodeTemplate node = _nodes[index];
EditorGUILayout.BeginVertical("box");
EditorGUILayout.BeginHorizontal();
node.Foldout = EditorGUILayout.Foldout(node.Foldout, $"Node {index + 1}" + (index == 0 ? " (START)" : ""), true);
GUILayout.FlexibleSpace();
if (_nodes.Count > 1 && GUILayout.Button("X", GUILayout.Width(25)))
{
_nodes.RemoveAt(index);
return;
}
EditorGUILayout.EndHorizontal();
if (node.Foldout)
{
EditorGUI.indentLevel++;
EditorGUILayout.LabelField("Dialogue Text", EditorStyles.miniBoldLabel);
node.NodeText = EditorGUILayout.TextArea(node.NodeText, GUILayout.Height(60));
EditorGUILayout.Space(5);
bool isLastNode = index == _nodes.Count - 1;
if (isLastNode)
{
EditorGUILayout.HelpBox("Last node = End of conversation (no choices)", MessageType.Info);
node.IsEndNode = true;
}
else
{
EditorGUILayout.LabelField("Available Choices", EditorStyles.miniBoldLabel);
for (int c = 0; c < node.Choices.Count; c++)
{
ChoiceTemplate choice = node.Choices[c];
EditorGUILayout.BeginVertical("box");
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"Choice {c + 1}", EditorStyles.boldLabel, GUILayout.Width(60));
if (GUILayout.Button("-", GUILayout.Width(25)))
{
node.Choices.RemoveAt(c);
break;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.LabelField("Choice Text:", GUILayout.Width(100));
choice.Text = EditorGUILayout.TextField(choice.Text);
EditorGUILayout.Space(3);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Target:", GUILayout.Width(100));
string[] nodeOptions = new string[_nodes.Count];
for (int n = 0; n < _nodes.Count; n++)
{
if (n == index)
nodeOptions[n] = $"Node {n + 1} (SELF - Loop)";
else if (n == _nodes.Count - 1)
nodeOptions[n] = $"Node {n + 1} (END)";
else
nodeOptions[n] = $"Node {n + 1}";
}
choice.TargetNodeIndex = Mathf.Clamp(choice.TargetNodeIndex, 0, _nodes.Count - 1);
int selectedIndex = EditorGUILayout.Popup(choice.TargetNodeIndex, nodeOptions);
choice.TargetNodeIndex = selectedIndex;
EditorGUILayout.EndHorizontal();
if (choice.TargetNodeIndex == index)
EditorGUILayout.HelpBox("! Loop detected: this choice returns to the same node", MessageType.Warning);
else if (choice.TargetNodeIndex < index)
EditorGUILayout.HelpBox("↩ Going back to Node " + (choice.TargetNodeIndex + 1), MessageType.Info);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(3);
}
if (GUILayout.Button("+ Add Choice"))
{
node.Choices.Add(new ChoiceTemplate
{
Text = $"New choice {node.Choices.Count + 1}",
TargetNodeIndex = Mathf.Min(index + 1, _nodes.Count - 1)
});
}
}
EditorGUI.indentLevel--;
}
EditorGUILayout.EndVertical();
EditorGUILayout.Space(5);
}
private OctoLocalizedTextData CreateLocalizedTextData(string text, string basePath, string suffix)
{
OctoLocalizedTextData textData = ScriptableObject.CreateInstance<OctoLocalizedTextData>();
textData.Init();
// Set English text by default
textData.Languages.Dictionary["English"] = text;
textData.Languages.Dictionary["French"] = ""; // Empty, user will fill later
string textDataPath = AssetDatabase.GenerateUniqueAssetPath($"{basePath}/text_{suffix}.asset");
AssetDatabase.CreateAsset(textData, textDataPath);
return textData;
}
private void CreateDialogue()
{
if (string.IsNullOrEmpty(_speakerName) || string.IsNullOrEmpty(_conversationName))
{
EditorUtility.DisplayDialog("Error", "Speaker name and conversation name are required!", "OK");
return;
}
if (!AssetDatabase.IsValidFolder(_savePath))
{
EditorUtility.DisplayDialog("Error", "Save path is not valid!", "OK");
return;
}
string folderPath = $"{_savePath}/{_conversationName}";
if (!AssetDatabase.IsValidFolder(folderPath))
{
string parentFolder = _savePath;
AssetDatabase.CreateFolder(parentFolder, _conversationName);
}
// Create speaker name localized text
OctoLocalizedTextData speakerNameData = CreateLocalizedTextData(_speakerName, folderPath, "SpeakerName");
List<DialogueNode> createdNodes = new List<DialogueNode>();
// Create nodes
for (int i = 0; i < _nodes.Count; i++)
{
NodeTemplate template = _nodes[i];
DialogueNode node = CreateInstance<DialogueNode>();
node.name = $"Node_{i + 1}";
// Create localized text for dialogue
node.DialogueText = CreateLocalizedTextData(template.NodeText, folderPath, $"Node{i + 1}_Text");
string nodePath = $"{folderPath}/{node.name}.asset";
AssetDatabase.CreateAsset(node, nodePath);
createdNodes.Add(node);
}
// Link choices
for (int i = 0; i < _nodes.Count; i++)
{
NodeTemplate template = _nodes[i];
DialogueNode currentNode = createdNodes[i];
foreach (ChoiceTemplate choiceTemplate in template.Choices)
{
int targetIndex = Mathf.Clamp(choiceTemplate.TargetNodeIndex, 0, createdNodes.Count - 1);
// Create localized text for choice
OctoLocalizedTextData choiceTextData = CreateLocalizedTextData(
choiceTemplate.Text,
folderPath,
$"Node{i + 1}_Choice{template.Choices.IndexOf(choiceTemplate)}"
);
DialogueChoiceData choice = new DialogueChoiceData
{
ChoiceText = choiceTextData,
NextNode = createdNodes[targetIndex]
};
currentNode.Choices.Add(choice);
}
EditorUtility.SetDirty(currentNode);
}
// Create conversation
DialogueConversation conversation = CreateInstance<DialogueConversation>();
conversation.SpeakerName = speakerNameData;
conversation.name = _conversationName;
conversation.DialogueNodes = createdNodes;
string conversationPath = $"{folderPath}/{_conversationName}.asset";
AssetDatabase.CreateAsset(conversation, conversationPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
EditorGUIUtility.PingObject(conversation);
Selection.activeObject = conversation;
EditorUtility.DisplayDialog(
"Success",
$"Conversation '{_conversationName}' created with {_nodes.Count} nodes!\n\n" +
$"Path: {folderPath}\n\n" +
$"📝 Note: All texts are in English by default.\n" +
$"Open each DialogueNode in the Inspector to add French translations!",
"OK"
);
}
}
#endif
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 863ff46c7eaea694e97b5e51782b7119
@@ -0,0 +1,8 @@
namespace Unheimlich.Features.Dialogue.Data
{
public enum DialogueDisplayMode
{
World,
Screen
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b10588959f3745441b157488d7a8f6e9
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using UnityEngine;
using OctoCore.Localization;
using Unheimlich.Features.DetectiveBoard.Data;
namespace Unheimlich.Features.Dialogue.Data
{
[CreateAssetMenu(fileName = "NewDialogueNode", menuName = "Unheimlich/Dialogue/Dialogue Node")]
public class DialogueNode : ScriptableObject
{
[Header("Content")]
[Tooltip("Localized text displayed for this dialogue node")]
public OctoLocalizedTextData DialogueText;
[Header("Choices")]
[Tooltip("List of available choices for this dialogue. If empty, this is an end node")]
public List<DialogueChoiceData> Choices = new List<DialogueChoiceData>();
[Header("End Node Settings")]
[Tooltip("For end nodes (no choices), delay in seconds before automatic close. 0 = requires player interaction to close")]
public float AutoCloseDelay = 3f;
[Header("Detective Board Integration")]
[Tooltip("Clue to unlock when this node is reached. Useful for end nodes to unlock evidence after a conversation")]
public ClueData ClueToUnlock;
public string GetLocalizedDialogueText()
{
if (DialogueText != null)
return OctoLocalization.GetLocalizedText(DialogueText);
return string.Empty;
}
public bool IsEndNode()
{
return Choices == null || Choices.Count == 0;
}
public bool HasValidChoices()
{
if (Choices == null || Choices.Count == 0)
return false;
foreach (var choice in Choices)
{
if (choice != null && choice.NextNode != null)
return true;
}
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c5adec41ffe2e0f4d83e2e75e31781d6
@@ -0,0 +1,123 @@
using UnityEngine;
namespace Unheimlich.Features.Dialogue.Data
{
[CreateAssetMenu(fileName = "NewObjectDescriptionSettings", menuName = "Unheimlich/Animation/Object Description Settings")]
public class ObjectDescriptionSettings : ScriptableObject
{
[Header("Spawn Settings")]
[Tooltip("Spawn mode for floating words")]
public ChoiceSpawnMode SpawnMode = ChoiceSpawnMode.AroundAnchor;
[Tooltip("Number of floating words to spawn around the object")]
[Range(1, 10)]
public int WordCount = 3;
[Tooltip("Radius around anchor where words spawn")]
public float SpawnRadius = 1.5f;
[Tooltip("Height offset from anchor")]
public float SpawnHeightOffset = 0f;
[Tooltip("Forward offset from anchor (to avoid clipping with object)")]
public float ForwardOffset = 0.5f;
[Tooltip("Tilt angle in degrees (rotation around Right axis for height adjustment)")]
[Range(-90f, 90f)]
public float TiltAngle = 0f;
[Tooltip("Should words face the player?")]
public bool FacePlayer = true;
[Tooltip("Duration of spawn animation")]
public float SpawnAnimationDuration = 1f;
[Tooltip("Spawn animation curve")]
public AnimationCurve SpawnCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[Header("Idle Behavior")]
[Tooltip("Should words float vertically?")]
public bool EnableFloating = true;
[Tooltip("Floating amplitude")]
public float FloatingAmplitude = 0.2f;
[Tooltip("Floating duration (full cycle)")]
public float FloatingDuration = 3f;
[Tooltip("Enable organic shake/drift movement around center point")]
public bool EnableOrganicShake = true;
[Tooltip("Radius around center point where words can drift")]
public float ShakeRadius = 0.15f;
[Tooltip("Speed of the shake movement")]
public float ShakeSpeed = 1.5f;
[Tooltip("Smoothness of shake transitions (higher = smoother)")]
public float ShakeSmoothness = 2f;
[Header("Ghost Trail Effect")]
[Tooltip("Enable ghost/shadow trail effect behind text")]
public bool EnableGhostTrail = false;
[Tooltip("Time delay for ghost trail in seconds")]
public float GhostTrailDelay = 0.4f;
[Tooltip("Transparency of the ghost trail (0 = invisible, 1 = opaque)")]
[Range(0f, 1f)]
public float GhostTrailAlpha = 0.5f;
[Header("Rotation")]
[Tooltip("Should container rotate continuously?")]
public bool EnableRotation = true;
[Tooltip("Axis of rotation")]
public RotationAxis RotationAxis = RotationAxis.Up;
[Tooltip("Rotation speed (in seconds for a full rotation)")]
public float RotationSpeed = 10f;
[Header("Convergence Behavior")]
[Tooltip("Minimum radius when cursor is on object (words converge here)")]
public float MinConvergenceRadius = 0.1f;
[Tooltip("Speed at which words converge to center")]
public float ConvergenceSpeed = 5f;
[Tooltip("Duration of the merge animation when words fuse together")]
public float MergeDuration = 0.3f;
[Tooltip("Scale multiplier during merge animation")]
public float MergeScalePulse = 1.2f;
[Tooltip("If true, merge only happens once and words stay merged. If false, words can merge/unmerge repeatedly.")]
public bool MergeOnce = false;
[Tooltip("Duration in seconds the merged word stays visible before disappearing (only used if mergeOnce is true)")]
public float MergeOnceDisplayDuration = 3f;
[Header("Player Distance")]
[Tooltip("Distance from object at which words appear")]
public float ActivationDistance = 5f;
[Tooltip("Distance beyond which words disappear")]
public float DeactivationDistance = 7f;
[Header("Visual Settings")]
[Tooltip("Enable distance-based scaling")]
public bool EnableDistanceScaling = true;
[Tooltip("Minimum distance for scaling calculation")]
public float MinDistance = 2f;
[Tooltip("Maximum distance for scaling calculation")]
public float MaxDistance = 10f;
[Tooltip("Scale multiplier at minimum distance")]
public float MinScaleMultiplier = 1f;
[Tooltip("Scale multiplier at maximum distance")]
public float MaxScaleMultiplier = 2.5f;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfa0e8eb3803d1c47bbde0fe4fb30a37
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7300e2dc12a6f7b4fac999cb3d7b07b5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using UnityEngine;
using UnityEditor;
using Unheimlich.Features.Dialogue.Data;
[CustomEditor(typeof(DialogueConversation))]
public class DialogueConversationEditor : Editor
{
private SerializedProperty _speakerNameProp;
private SerializedProperty _displayModeProp;
private SerializedProperty _dialogueNodesProp;
[MenuItem("Unheimlich/Tools/Dialogue/Refresh All Conversations")]
public static void RefreshAllConversations()
{
string[] guids = AssetDatabase.FindAssets("t:DialogueConversation");
int count = 0;
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
DialogueConversation conv = AssetDatabase.LoadAssetAtPath<DialogueConversation>(path);
if (conv != null)
{
EditorUtility.SetDirty(conv);
count++;
}
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"<color=green>[Dialogue Refresher]</color> {count} conversations ont été rafraîchies !");
}
private void OnEnable()
{
_speakerNameProp = serializedObject.FindProperty("SpeakerName");
_displayModeProp = serializedObject.FindProperty("DisplayMode");
_dialogueNodesProp = serializedObject.FindProperty("DialogueNodes");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Conversation Setup", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_speakerNameProp);
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Display Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_displayModeProp, new GUIContent("Display Mode"));
EditorGUILayout.HelpBox(
"World: Dialogue text appears in 3D space\n" +
"Screen: Dialogue text appears on Canvas UI\n" +
"Choices always appear in World space.",
MessageType.Info);
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Dialogue Flow", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("The first DialogueNode in the list will be the starting point of the conversation.", MessageType.Info);
EditorGUILayout.PropertyField(_dialogueNodesProp, new GUIContent("Dialogue Nodes"), true);
EditorGUILayout.Space(10);
if (GUILayout.Button("Create New Dialogue Node", GUILayout.Height(30)))
CreateNewDialogueNode();
EditorGUILayout.Space(10);
DialogueConversation conversation = (DialogueConversation)target;
if (conversation.DialogueNodes != null && conversation.DialogueNodes.Count > 0)
{
EditorGUILayout.LabelField("Conversation Overview", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"Total Nodes: {conversation.DialogueNodes.Count}");
DialogueNode firstNode = conversation.GetFirstNode();
if (firstNode != null)
EditorGUILayout.LabelField($"Starting Node: {firstNode.name}");
}
serializedObject.ApplyModifiedProperties();
}
private void CreateNewDialogueNode()
{
DialogueConversation conversation = (DialogueConversation)target;
string path = AssetDatabase.GetAssetPath(conversation);
string directory = System.IO.Path.GetDirectoryName(path);
DialogueNode newNode = CreateInstance<DialogueNode>();
newNode.name = $"Node_{conversation.DialogueNodes.Count + 1}";
string nodePath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{newNode.name}.asset");
AssetDatabase.CreateAsset(newNode, nodePath);
conversation.DialogueNodes.Add(newNode);
EditorUtility.SetDirty(conversation);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
EditorGUIUtility.PingObject(newNode);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3385c171e333c347bcf6f68b953acdb
@@ -0,0 +1,112 @@
using UnityEngine;
using UnityEditor;
using Unheimlich.Features.Dialogue.Data;
[CustomEditor(typeof(DialogueNode))]
public class DialogueNodeEditor : Editor
{
private SerializedProperty _dialogueTextProp;
private SerializedProperty _choicesProp;
private SerializedProperty _autoCloseDelayProp;
private SerializedProperty _clueToUnlockProp;
private void OnEnable()
{
_dialogueTextProp = serializedObject.FindProperty("DialogueText");
_choicesProp = serializedObject.FindProperty("Choices");
_autoCloseDelayProp = serializedObject.FindProperty("AutoCloseDelay");
_clueToUnlockProp = serializedObject.FindProperty("ClueToUnlock");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Node Content", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_dialogueTextProp);
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Choices", EditorStyles.boldLabel);
DialogueNode node = (DialogueNode)target;
if (node.IsEndNode())
{
EditorGUILayout.HelpBox("This node is an end of conversation (no choices).", MessageType.Info);
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("End Node Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_autoCloseDelayProp, new GUIContent("Auto Close Delay (seconds)"));
float delay = _autoCloseDelayProp.floatValue;
if (delay > 0)
EditorGUILayout.HelpBox($"The dialogue will automatically close after {delay:F1}s with a fade out.", MessageType.None);
else
EditorGUILayout.HelpBox("Player must press 'Interact' to close the dialogue.", MessageType.None);
EditorGUILayout.Space(5);
}
else if (!node.HasValidChoices())
EditorGUILayout.HelpBox("Warning: Some choices don't have a NextNode assigned.", MessageType.Warning);
EditorGUILayout.PropertyField(_choicesProp, true);
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Detective Board Integration", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_clueToUnlockProp, new GUIContent("Clue To Unlock"));
if (_clueToUnlockProp.objectReferenceValue != null)
{
EditorGUILayout.HelpBox("This clue will be unlocked on the Detective Board when this dialogue node is reached.", MessageType.Info);
}
else
{
EditorGUILayout.HelpBox("Optional: Assign a ClueData to unlock an evidence on the Detective Board when reaching this node.", MessageType.None);
}
EditorGUILayout.Space(10);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Add Choice"))
{
AddNewChoice();
}
if (node.Choices != null && node.Choices.Count > 0)
{
if (GUILayout.Button("Clear All Choices"))
{
if (EditorUtility.DisplayDialog("Clear Choices",
"Are you sure you want to remove all choices?",
"Yes", "Cancel"))
{
node.Choices.Clear();
EditorUtility.SetDirty(node);
}
}
}
EditorGUILayout.EndHorizontal();
serializedObject.ApplyModifiedProperties();
}
private void AddNewChoice()
{
DialogueNode node = (DialogueNode)target;
DialogueChoiceData newChoice = new DialogueChoiceData
{
ChoiceText = null, // Will need to assign OctoLocalizedTextData manually
NextNode = null
};
node.Choices.Add(newChoice);
EditorUtility.SetDirty(node);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91d402e79d02c8246b21d7f72df91e84
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f76a0c67d5a1bd4489b6055f3e7077f3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,255 @@
using UnityEngine;
using System;
using Unheimlich.Features.Player;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue
{
/// <summary>
/// Manages gaze-based focus detection for dialogue choices using raycasts.
/// </summary>
public class DialogueFocusSystem
{
private readonly DialogueManager _manager;
private readonly Camera _mainCamera;
private readonly CameraEffects _cameraEffects;
private ChoiceFocusHandler _currentFocusedHandler;
private float _focusTimer = 0f;
private float _retentionTimer = 0f;
private float _raycastTimer = 0f;
private bool _isFocusLocked = false;
private readonly LayerMask _choiceLayer;
private readonly float _focusRange;
private readonly float _focusTime;
private readonly float _raycastInterval;
private readonly float _recoveryDuration;
private readonly float _focusRetentionTime;
private readonly float _fallbackShakeIntensity;
public event Action<DialogueChoiceData> OnChoiceMade;
public DialogueFocusSystem(
DialogueManager manager,
Camera mainCamera,
CameraEffects cameraEffects,
LayerMask choiceLayer,
float focusRange,
float focusTime,
float raycastInterval,
float recoveryDuration,
float focusRetentionTime,
float fallbackShakeIntensity)
{
_manager = manager;
_mainCamera = mainCamera;
_cameraEffects = cameraEffects;
_choiceLayer = choiceLayer;
_focusRange = focusRange;
_focusTime = focusTime;
_raycastInterval = raycastInterval;
_recoveryDuration = recoveryDuration;
_focusRetentionTime = focusRetentionTime;
_fallbackShakeIntensity = fallbackShakeIntensity;
}
/// <summary>
/// Updates the focus detection system. Should be called every frame during active dialogue.
/// Performs raycasts at configured intervals to detect choice focus.
/// </summary>
public void Update()
{
if (!_manager.IsDialogueActive || !_manager.CurrentDialogueUI.AreChoicesVisible())
return;
_raycastTimer += Time.deltaTime;
if (_raycastTimer >= _raycastInterval)
{
CheckForChoiceFocus();
_raycastTimer = 0f;
}
}
/// <summary>
/// Performs raycast from camera center to detect focused dialogue choices.
/// Manages focus state transitions, retention timers, visual feedback, and choice selection.
/// Implements stabilization to prevent rapid focus switching between choices.
/// </summary>
private void CheckForChoiceFocus()
{
if (_isFocusLocked)
{
if (_retentionTimer > 0f)
_retentionTimer -= Time.deltaTime;
else
_isFocusLocked = false;
return;
}
if (_mainCamera == null)
return;
ChoiceFocusHandler hitHandler = null;
if (RaycastHelper.RaycastFromCameraCenter(_mainCamera, _focusRange, _choiceLayer, out RaycastHit hit))
{
hitHandler = hit.collider.GetComponent<ChoiceFocusHandler>();
if (_cameraEffects != null && hitHandler != null)
_cameraEffects.ApplyAimAssist(hit.collider.bounds.center);
}
if (hitHandler != _currentFocusedHandler)
{
_retentionTimer -= Time.deltaTime;
if (hitHandler != null && _retentionTimer <= 0f)
{
if (_currentFocusedHandler != null)
{
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
ResetAllUnfocusEffects();
}
_currentFocusedHandler = hitHandler;
_focusTimer = 0f;
_retentionTimer = _focusRetentionTime;
if (_cameraEffects != null)
_cameraEffects.LockAim(true);
ApplyUnfocusEffectToOthers(hitHandler);
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
bool shouldRotate = settings == null || settings.EnableRotation;
if (shouldRotate && _manager.CurrentDialogueUI != null)
{
float focusSpeed = settings != null ? settings.FocusRotationSpeed : 60f;
_manager.CurrentDialogueUI.RotationAnimation(focusSpeed);
}
}
}
else if (hitHandler != null)
{
_retentionTimer = _focusRetentionTime;
}
bool isFocusActive = _currentFocusedHandler != null && (_retentionTimer > 0f);
if (isFocusActive)
{
_focusTimer += Time.deltaTime;
float progress = _focusTimer / _focusTime;
_currentFocusedHandler.ApplyFocusFeedback(progress);
DialogueAnimationSettings currentSettings = _manager.CurrentAnimationSettings;
bool shouldShake = currentSettings == null || currentSettings.EnableCameraShakeOnFocus;
if (shouldShake && _cameraEffects != null)
{
float shakeIntensity = currentSettings != null ? currentSettings.CameraShakeIntensity : _fallbackShakeIntensity;
float shakeSmoothness = currentSettings != null ? currentSettings.CameraShakeSmoothness : 10f;
_cameraEffects.ShakeCamera(progress / shakeIntensity, shakeSmoothness);
}
if (_focusTimer >= _focusTime)
{
ChoiceFocusHandler selectedHandler = _currentFocusedHandler;
if (selectedHandler.DialogueData != null)
{
OnChoiceMade?.Invoke(selectedHandler.DialogueData);
DialogueNode nextNode = selectedHandler.GetNextNode();
_manager.GoToNextNode(nextNode);
}
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
bool shouldRotate = settings == null || settings.EnableRotation;
if (shouldRotate && _manager.CurrentDialogueUI != null)
{
float normalSpeed = settings != null ? settings.NormalRotationSpeed : 36f;
_manager.CurrentDialogueUI.RotationAnimation(normalSpeed);
}
ResetAnimationTimers();
ResetAllUnfocusEffects();
selectedHandler.ResetFeedback(_recoveryDuration);
if (_cameraEffects != null)
_cameraEffects.LockAim(false);
}
}
else if (_currentFocusedHandler != null && _retentionTimer <= 0f)
{
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
ResetAllUnfocusEffects();
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
bool shouldRotate = settings == null || settings.EnableRotation;
if (shouldRotate && _manager.CurrentDialogueUI != null)
{
float normalSpeed = settings != null ? settings.NormalRotationSpeed : 36f;
_manager.CurrentDialogueUI.RotationAnimation(normalSpeed);
}
ResetAnimationTimers();
if (_cameraEffects != null)
_cameraEffects.LockAim(false);
}
}
private void ApplyUnfocusEffectToOthers(ChoiceFocusHandler focusedHandler)
{
if (_manager.CurrentDialogueUI == null || _manager.CurrentDialogueUI.ChoiceContainer == null)
return;
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
float shakeSlowdown = settings != null ? settings.UnfocusedShakeSpeedMultiplier : 0.3f;
foreach (Transform child in _manager.CurrentDialogueUI.ChoiceContainer.transform)
{
ChoiceFocusHandler handler = child.GetComponent<ChoiceFocusHandler>();
if (handler != null && handler != focusedHandler)
{
handler.SetShakeSpeedMultiplier(shakeSlowdown);
handler.ApplySketchFade();
}
}
}
private void ResetAllUnfocusEffects()
{
if (_manager.CurrentDialogueUI == null || _manager.CurrentDialogueUI.ChoiceContainer == null)
return;
foreach (Transform child in _manager.CurrentDialogueUI.ChoiceContainer.transform)
{
ChoiceFocusHandler handler = child.GetComponent<ChoiceFocusHandler>();
if (handler != null)
{
handler.SetShakeSpeedMultiplier(1f);
handler.ResetSketchFade();
}
}
}
private void ResetAnimationTimers()
{
_currentFocusedHandler = null;
_focusTimer = 0f;
_retentionTimer = 0f;
}
public void UnlockFocus()
{
_isFocusLocked = false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 22514e9ce0f2e6c4b9f5f718ae139b09
@@ -0,0 +1,293 @@
using UnityEngine;
using System;
using Unheimlich.Core;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.DetectiveBoard.Data;
using Unheimlich.Features.DetectiveBoard;
using Unheimlich.Features.Player;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Features.Dialogue;
using System.Collections.Generic;
using InputManager = Unheimlich.Core.Input.InputManager;
namespace Unheimlich.Features.Dialogue
{
public class DialogueManager : Singleton<DialogueManager>
{
[Header("State")]
[SerializeField] private bool IsInDialogue = false;
[Header("Focus Settings")]
[SerializeField] private LayerMask _choiceLayer;
[SerializeField] private float _focusRange = 5f;
[SerializeField] private float _focusTime = 1.5f;
[SerializeField] private float _raycastInterval = 0.05f;
[Header("Focus Stabilization")]
[SerializeField] private float _recoveryDuration = 0.5f;
[SerializeField] private float _focusRetentionTime = 0.1f;
[Tooltip("Fallback camera shake intensity if not set in DialogueAnimationSettings")]
[SerializeField] private float _fallbackShakeIntensity = 30f;
public DialogueAnimationSettings CurrentAnimationSettings => _currentAnimationSettings;
public bool IsDialogueActive => IsInDialogue;
public Transform CurrentChoiceAnchor => _currentChoiceAnchor;
public DialogueUI CurrentDialogueUI => _activeDialogueUI;
public object CurrentOwner { get; private set; }
public DialogueConversation CurrentConversation => _currentConversation;
public DialogueNode CurrentNode => _currentNode;
private bool _blurEnabled;
public event Action<DialogueChoiceData> OnChoiceMade;
public event Action<object> OnDialogueCompleted;
private DialogueFocusSystem _focusSystem;
private Camera _mainCamera;
private DialogueAnimationSettings _currentAnimationSettings;
private DialogueUI _activeDialogueUI;
private Transform _currentChoiceAnchor;
private DialogueConversation _currentConversation;
private DialogueNode _currentNode;
private float _autoCloseTimer = 0f;
private bool _isWaitingForAutoClose = false;
protected override void OnAwake() { }
protected override void OnEnabled()
{
// With Scene Reload disabled, Start() doesn't run again each session.
// We recreate the focus system to clear any stale locked state (_isFocusLocked,
// _focusTimer, _currentFocusedHandler) that may have carried over.
if (_focusSystem != null)
{
_mainCamera = Camera.main;
InitFocusSystem();
// Also reset dialogue state in case we exited mid-dialogue
IsInDialogue = false;
_isWaitingForAutoClose = false;
_autoCloseTimer = 0f;
}
}
private void Start()
{
_mainCamera = Camera.main;
InitFocusSystem();
}
/// <summary>
/// Creates or recreates the DialogueFocusSystem with the current inspector settings.
/// Called from Start and OnEnabled (domain-reload-less re-entry).
/// </summary>
private void InitFocusSystem()
{
_focusSystem = new DialogueFocusSystem(
this,
_mainCamera,
PlayerController.Instance?.CameraEffects,
_choiceLayer,
_focusRange,
_focusTime,
_raycastInterval,
_recoveryDuration,
_focusRetentionTime,
_fallbackShakeIntensity
);
_focusSystem.OnChoiceMade += (choice) => OnChoiceMade?.Invoke(choice);
}
private void Update()
{
if (!IsInDialogue)
return;
if (_activeDialogueUI == null)
{
Debug.LogError("[DialogueManager] IsInDialogue is true but _activeDialogueUI is null. Forcing dialogue end.", this);
IsInDialogue = false;
return;
}
if (!_activeDialogueUI.AreChoicesVisible())
{
if (_isWaitingForAutoClose && _currentNode != null && _currentNode.AutoCloseDelay > 0f)
{
_autoCloseTimer += Time.deltaTime;
if (_autoCloseTimer >= _currentNode.AutoCloseDelay)
{
_activeDialogueUI.FadeOutAndHide(() =>
{
OnDialogueCompleted?.Invoke(CurrentOwner);
CleanupDialogue();
});
IsInDialogue = false;
_isWaitingForAutoClose = false;
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
return;
}
}
var interactAction = InputManager.GetInteractAction();
if (interactAction != null && interactAction.WasPressedThisFrame())
{
EndDialogue();
}
}
else
{
_focusSystem?.Update();
}
}
public void StartConversation(DialogueConversation conversation, Transform choiceAnchor = null, DialogueAnimationSettings animationSettings = null, DialogueUI customDialogueUI = null, object owner = null, bool showBlur = false)
{
if (conversation == null)
{
Debug.LogError("Cannot start conversation: conversation is null");
return;
}
DialogueNode firstNode = conversation.GetFirstNode();
if (firstNode == null)
{
Debug.LogError($"Cannot start conversation {conversation.name}: no dialogue nodes found");
return;
}
_currentConversation = conversation;
_currentNode = firstNode;
_currentChoiceAnchor = choiceAnchor;
_currentAnimationSettings = animationSettings;
_activeDialogueUI = customDialogueUI;
CurrentOwner = owner;
_blurEnabled = showBlur;
if (_activeDialogueUI == null)
{
Debug.LogError("[DialogueManager] StartConversation called with no DialogueUI. Assign a Local Dialogue UI on the DialogueTriggerZone that triggered this conversation.", this);
return;
}
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
PlayerEvents.TriggerEnterDialogue();
ShowCurrentNode();
}
private void ShowCurrentNode()
{
if (_currentNode == null || _currentConversation == null)
return;
UnlockFocus();
IsInDialogue = true;
if (_currentNode.ClueToUnlock != null)
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_currentNode.ClueToUnlock);
}
else
{
Debug.LogWarning($"[DialogueManager] Cannot unlock clue '{_currentNode.ClueToUnlock.ClueID}': DetectiveBoardManager not found");
}
}
if(_currentChoiceAnchor == null)
{
Debug.LogWarning("No choice anchor found!");
return;
}
Transform anchor = _currentChoiceAnchor;
string speakerName = _currentConversation.GetLocalizedSpeakerName();
string dialogueText = _currentNode.GetLocalizedDialogueText();
DialogueDisplayMode displayMode = _currentConversation.DisplayMode;
_activeDialogueUI.ShowDialogueBox(speakerName, dialogueText, displayMode);
_autoCloseTimer = 0f;
_isWaitingForAutoClose = false;
if (_currentNode.Choices != null && _currentNode.Choices.Count > 0)
{
List<DialogueChoiceData> validChoices = new List<DialogueChoiceData>();
foreach (var choiceData in _currentNode.Choices)
{
if (choiceData != null && choiceData.IsValid())
validChoices.Add(choiceData);
}
if (validChoices.Count > 0)
_activeDialogueUI.ShowChoices(validChoices, anchor, _currentAnimationSettings);
}
else
_isWaitingForAutoClose = true;
}
public void GoToNextNode(DialogueNode nextNode)
{
if (nextNode == null)
{
OnDialogueCompleted?.Invoke(CurrentOwner);
EndDialogue();
return;
}
UnlockFocus();
if (_activeDialogueUI != null)
_activeDialogueUI.HideChoices();
_currentNode = nextNode;
ShowCurrentNode();
}
public void EndDialogue()
{
IsInDialogue = false;
if (_activeDialogueUI != null)
_activeDialogueUI.HideDialogueBox();
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
PlayerEvents.TriggerExitDialogue();
CleanupDialogue();
}
private void CleanupDialogue()
{
_currentChoiceAnchor = null;
_currentAnimationSettings = null;
_activeDialogueUI = null;
CurrentOwner = null;
_blurEnabled = false;
_currentConversation = null;
_currentNode = null;
_autoCloseTimer = 0f;
_isWaitingForAutoClose = false;
}
public void UnlockFocus()
{
_focusSystem?.UnlockFocus();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bd83d1e8e266cff41b8a5c2928f1f80f
@@ -0,0 +1,84 @@
using UnityEngine;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Dialogue.UI;
namespace Unheimlich.Features.Dialogue
{
public class DialogueStateHandler
{
public bool IsCompleted { get; private set; }
public bool HasPausedDialogue => _pausedConversation != null && _pausedNode != null;
private DialogueConversation _pausedConversation;
private DialogueNode _pausedNode;
private Transform _pausedChoiceAnchor;
private DialogueAnimationSettings _pausedAnimationSettings;
private DialogueUI _pausedDialogueUI;
private object _owner;
public DialogueStateHandler(object owner)
{
_owner = owner;
}
public void PauseDialogue()
{
if (DialogueManager.Instance == null || !DialogueManager.Instance.IsDialogueActive)
return;
DialogueManager dialogueManager = DialogueManager.Instance;
_pausedConversation = dialogueManager.CurrentConversation;
_pausedNode = dialogueManager.CurrentNode;
_pausedChoiceAnchor = dialogueManager.CurrentChoiceAnchor;
_pausedAnimationSettings = dialogueManager.CurrentAnimationSettings;
_pausedDialogueUI = dialogueManager.CurrentDialogueUI;
DialogueManager.Instance.EndDialogue();
}
public void ResumeDialogue()
{
if (!HasPausedDialogue || DialogueManager.Instance == null)
return;
DialogueManager.Instance.StartConversation(
_pausedConversation,
_pausedChoiceAnchor,
_pausedAnimationSettings,
_pausedDialogueUI,
_owner
);
ClearPausedDialogue();
}
public void MarkAsCompleted()
{
IsCompleted = true;
ClearPausedDialogue();
}
public void AllowRetrigger()
{
IsCompleted = false;
}
public void Reset()
{
IsCompleted = false;
ClearPausedDialogue();
}
private void ClearPausedDialogue()
{
_pausedConversation = null;
_pausedNode = null;
_pausedChoiceAnchor = null;
_pausedAnimationSettings = null;
_pausedDialogueUI = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b328dc63103543940898cfa2dbd4ec36
@@ -0,0 +1,152 @@
using UnityEngine;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Core.Events;
namespace Unheimlich.Features.Dialogue
{
/// <summary>
/// Trigger zone that starts a dialogue sequence when player enters.
/// Supports pause/resume on exit/enter and optional one-time triggering.
/// </summary>
public class DialogueTriggerZone : TriggerZoneBase
{
[Header("Dialogue Reference")]
[Tooltip("DialogueConversation to use for this trigger zone")]
[SerializeField] private DialogueConversation _conversation;
[Header("Trigger Settings")]
[Tooltip("Can the dialogue be retriggered after completion")]
[SerializeField] private bool _isRetriggerable = false;
protected override bool IsRetriggerable => _isRetriggerable;
[Header("Distance Check")]
[Tooltip("Pause dialogue if player exits the trigger zone")]
[SerializeField] private bool _pauseOnExit = true;
[Header("Floating Word Anchor")]
[SerializeField] private Transform _floatingWordAnchor;
[Header("Animation Settings")]
[SerializeField] private DialogueAnimationSettings _animationSettings;
[Header("UI Reference")]
[Tooltip("Local DialogueUI for this trigger zone. Uses DialogueManager's UI if null")]
[SerializeField] private DialogueUI _localDialogueUI;
[Header("Blur Effect")]
[Tooltip("Enable blur effect when dialogue starts")]
[SerializeField] private bool _enableBlurEffect = true;
private bool _hasTriggered = false;
private DialogueStateHandler _dialogueState;
protected override void Awake()
{
base.Awake();
_dialogueState = new DialogueStateHandler(this);
if (_floatingWordAnchor == null)
_floatingWordAnchor = transform;
}
protected override void OnPlayerEntered(Collider playerCollider)
{
if (_dialogueState.IsCompleted && !_isRetriggerable)
return;
if (_dialogueState.HasPausedDialogue)
{
_dialogueState.ResumeDialogue();
}
else if (!_hasTriggered || (_isRetriggerable && _dialogueState.IsCompleted))
{
TriggerDialogue();
}
}
protected override void OnPlayerExited(Collider playerCollider)
{
if (_pauseOnExit && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
{
if ((Object)DialogueManager.Instance.CurrentOwner == this)
_dialogueState.PauseDialogue();
}
}
private void TriggerDialogue()
{
if (DialogueManager.Instance == null)
{
Debug.LogError("DialogueManager not found in scene!", this);
return;
}
if (_conversation == null)
{
Debug.LogError($"{gameObject.name}: No conversation assigned!", this);
return;
}
_hasTriggered = true;
DialogueManager.Instance.StartConversation(
_conversation,
_floatingWordAnchor,
_animationSettings,
_localDialogueUI,
this,
_enableBlurEffect
);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
}
private void OnDialogueCompleted(object owner)
{
if ((Object)owner == this)
{
_dialogueState.MarkAsCompleted();
if (_isRetriggerable)
{
_hasTriggered = false;
_dialogueState.AllowRetrigger();
}
}
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
public void ResetTrigger()
{
_hasTriggered = false;
_dialogueState.Reset();
ResetZone();
}
private void OnDestroy()
{
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
private void OnValidate()
{
if (_localDialogueUI == null)
Debug.LogWarning($"[DialogueTriggerZone] '{gameObject.name}' has no Local Dialogue UI assigned. Assign the appropriate DialogueUI prefab variant (World, NPC, Room, Screen, etc.).", this);
}
private void OnDrawGizmosSelected()
{
if (_floatingWordAnchor != null)
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(_floatingWordAnchor.position, 0.2f);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f33be20afd5d4c843a800ba1d8dd8437
@@ -0,0 +1,142 @@
using UnityEngine;
using Unheimlich.Core.Interaction;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Player;
using Unheimlich.Features.Dialogue.UI;
namespace Unheimlich.Features.Dialogue
{
/// <summary>
/// NPC interactable that starts dialogue conversations.
/// Rotates to face player during dialogue and pauses if player moves too far away.
/// </summary>
public class NPCDialogueTrigger : InteractableBase
{
[Header("Dialogue Reference")]
[Tooltip("DialogueConversation to use for this NPC")]
public DialogueConversation Conversation;
[Header("Trigger Settings")]
[Tooltip("Can the dialogue be retriggered after completion")]
[SerializeField] private bool _isRetriggerable = true;
[Header("Floating Word Setting")]
[SerializeField] private Transform _floatingWordAnchor;
[Header("Animation Settings")]
[SerializeField] private DialogueAnimationSettings _animationSettings;
[Header("UI Reference")]
[Tooltip("Local DialogueUI for this NPC. Uses DialogueManager's UI if null")]
[SerializeField] private DialogueUI _localDialogueUI;
[Header("Distance Settings")]
[Tooltip("Maximum distance before dialogue pauses")]
[SerializeField] private float _maxDialogueDistance = 5f;
[Header("Blur Effect")]
[Tooltip("Enable blur effect when dialogue starts")]
[SerializeField] private bool _enableBlurEffect = false;
public Transform FloatingWordAnchor => _floatingWordAnchor;
private bool _isDialogueActive = false;
private DialogueStateHandler _dialogueState;
private void Awake()
{
_dialogueState = new DialogueStateHandler(this);
}
private void Start()
{
InteractionText = "Talk";
}
private void Update()
{
if (_isDialogueActive && PlayerController.Instance != null)
{
float distance = Vector3.Distance(transform.position, PlayerController.Instance.transform.position);
if (distance > _maxDialogueDistance && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
{
if ((Object)DialogueManager.Instance.CurrentOwner == this)
{
_dialogueState.PauseDialogue();
_isDialogueActive = false;
}
}
else
LookAtPlayer();
}
}
public override void Interact()
{
if (DialogueManager.Instance == null)
return;
if (_dialogueState.IsCompleted && !_isRetriggerable)
return;
if (_dialogueState.HasPausedDialogue)
{
_dialogueState.ResumeDialogue();
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
}
else
{
if (Conversation == null)
{
Debug.LogWarning($"NPC {gameObject.name} has no conversation configured!");
return;
}
DialogueManager.Instance.StartConversation(
Conversation,
_floatingWordAnchor,
_animationSettings,
_localDialogueUI,
this,
_enableBlurEffect
);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
}
}
private void OnDialogueCompleted(object owner)
{
if ((Object)owner == this)
{
_dialogueState.MarkAsCompleted();
if (_isRetriggerable)
_dialogueState.AllowRetrigger();
_isDialogueActive = false;
}
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
private void OnDestroy()
{
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
public void LookAtPlayer()
{
if (PlayerController.Instance == null) return;
Vector3 lookAtPosition = PlayerController.Instance.transform.position;
lookAtPosition.y = transform.position.y;
transform.LookAt(lookAtPosition);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8696519b7eabaab4dbe80f2a4744cea0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ecb8fa7bbab6024478fc808bee6bcde4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using UnityEngine;
namespace Unheimlich.Features.Dialogue.UI
{
/// <summary>
/// Makes UI element always face the main camera.
/// Commonly used for world-space UI elements like floating words.
/// </summary>
public class BillboardUI : MonoBehaviour
{
private Transform _cameraTransform;
private void Awake()
{
_cameraTransform = Camera.main != null ? Camera.main.transform : null;
if (_cameraTransform == null)
Debug.LogWarning($"BillboardUI on {gameObject.name}: Main camera not found!");
}
private void Start() { }
private void LateUpdate()
{
if (_cameraTransform != null)
{
transform.LookAt(transform.position + _cameraTransform.rotation * Vector3.forward,
_cameraTransform.rotation * Vector3.up);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 222282a8dbdde4d418b8c85126583bf1
@@ -0,0 +1,582 @@
using UnityEngine;
using TMPro;
using System.Collections.Generic;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Player;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue.UI
{
/// <summary>
/// Manages individual dialogue choice button behavior including visual feedback during focus,
/// dynamic collider sizing, and smooth animations. Responds to player gaze through the focus system.
/// </summary>
public class ChoiceFocusHandler : MonoBehaviour
{
[Header("Components")]
[SerializeField] private TextMeshProUGUI _choiceTextComponent;
[SerializeField] private TextMeshProUGUI _ghostTextComponent;
public DialogueChoiceData DialogueData { get; private set; }
public string GetChoiceText()
{
return DialogueData?.GetLocalizedChoiceText() ?? string.Empty;
}
public DialogueNode GetNextNode()
{
return DialogueData?.NextNode;
}
private FloatingWordsSpawner.SpawnConfig _config;
private bool _hasConfig;
[Header("Local Feedback Settings")]
[SerializeField] private float _focusLerpSpeed = 10f;
[SerializeField] private float _colliderSizeMultiplier = 1.4f;
[SerializeField] private float _choiceDistanceFromPlayer = 1f;
[Header("Text Sizing Settings")]
[Tooltip("Maximum width for short texts (1-3 words)")]
[SerializeField] private float _shortTextMaxWidth = 80f;
[Tooltip("Maximum width for medium texts (4-5 words)")]
[SerializeField] private float _mediumTextMaxWidth = 120f;
[Tooltip("Maximum width for long texts (6-10 words)")]
[SerializeField] private float _longTextMaxWidth = 200f;
[Tooltip("Maximum width for very long texts (11+ words)")]
[SerializeField] private float _veryLongTextMaxWidth = 250f;
[Tooltip("Minimum height for text container")]
[SerializeField] private float _minTextHeight = 40f;
private const int FOCUSED_SORTING_ORDER = 10;
private const float MAX_FOCUS_SCALE = 1.2f;
private BoxCollider _choiceCollider;
private Canvas _canvas;
private int _originalSortingOrder;
private Vector3 _startLocalPos;
private float _originalScale;
private float _originalTextScale = 1f;
private int _textScaleTweenId = -1;
private bool _positionCaptured = false;
private Vector3 _shakeOffset = Vector3.zero;
private Vector3 _shakeTarget = Vector3.zero;
private float _shakeTimer = 0f;
private bool _isShaking = false;
private float _shakeSpeedMultiplier = 1f;
private bool _disableFloatingEffects = false;
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
private float _historyTimer = 0f;
private const float HISTORY_SAMPLE_RATE = 0.016f;
private SketchFadeEffect _sketchFadeEffect;
void Awake()
{
_canvas = GetComponentInParent<Canvas>();
if (_canvas != null)
_originalSortingOrder = _canvas.sortingOrder;
}
void Update()
{
if (_isShaking)
UpdateOrganicShake();
UpdateGhostTrail();
}
private void SetupGhostTrail()
{
if (!GetEnableGhostTrail())
return;
if (_ghostTextComponent == null && _choiceTextComponent != null)
{
GameObject ghostObj = new GameObject($"GhostText_{gameObject.name}");
ghostObj.transform.SetParent(transform.parent, false);
ghostObj.transform.localPosition = transform.localPosition;
ghostObj.transform.localRotation = transform.localRotation;
ghostObj.transform.localScale = transform.localScale;
ghostObj.layer =gameObject.layer;
_ghostTextComponent = ghostObj.AddComponent<TextMeshProUGUI>();
_ghostTextComponent.font = _choiceTextComponent.font;
_ghostTextComponent.fontSize = _choiceTextComponent.fontSize;
_ghostTextComponent.alignment = _choiceTextComponent.alignment;
_ghostTextComponent.textWrappingMode = _choiceTextComponent.textWrappingMode;
_ghostTextComponent.text = _choiceTextComponent.text;
_ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta;
_ghostTextComponent.rectTransform.anchorMin = _choiceTextComponent.rectTransform.anchorMin;
_ghostTextComponent.rectTransform.anchorMax = _choiceTextComponent.rectTransform.anchorMax;
_ghostTextComponent.rectTransform.pivot = _choiceTextComponent.rectTransform.pivot;
Color ghostColor = _choiceTextComponent.color;
ghostColor.a = GetGhostTrailAlpha();
_ghostTextComponent.color = ghostColor;
_positionHistory.Clear();
}
}
private void UpdateGhostTrail()
{
if (!GetEnableGhostTrail() || _ghostTextComponent == null || _choiceTextComponent == null)
return;
_historyTimer += Time.deltaTime;
if (_historyTimer >= HISTORY_SAMPLE_RATE)
{
_positionHistory.Enqueue(transform.localPosition);
_historyTimer = 0f;
int maxHistorySize = Mathf.CeilToInt(GetGhostTrailDelay() / HISTORY_SAMPLE_RATE);
while (_positionHistory.Count > maxHistorySize && _positionHistory.Count > 0)
_positionHistory.Dequeue();
}
if (_positionHistory.Count > 0)
{
Vector3 delayedPosition = _positionHistory.Peek();
_ghostTextComponent.transform.localPosition = delayedPosition;
_ghostTextComponent.transform.localRotation = transform.localRotation;
}
else
{
_ghostTextComponent.transform.localPosition = transform.localPosition;
_ghostTextComponent.transform.localRotation = transform.localRotation;
}
_ghostTextComponent.text = _choiceTextComponent.text;
_ghostTextComponent.fontSize = _choiceTextComponent.fontSize;
_ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta;
Color ghostColor = _choiceTextComponent.color;
ghostColor.a = GetGhostTrailAlpha();
_ghostTextComponent.color = ghostColor;
}
private void UpdateOrganicShake()
{
if (!GetEnableOrganicShake())
return;
_shakeTimer += Time.deltaTime * GetShakeSpeed() * _shakeSpeedMultiplier;
if (_shakeTimer >= 1f)
{
_shakeTimer = 0f;
_shakeTarget = Random.insideUnitSphere * GetShakeRadius();
}
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * GetShakeSmoothness());
if (!_disableFloatingEffects)
transform.localPosition = _startLocalPos + _shakeOffset;
}
public Vector3 GetShakeOffset(float convergenceFactor)
{
return _shakeOffset * convergenceFactor;
}
public void StartOrganicShake()
{
if (GetEnableOrganicShake())
{
_isShaking = true;
_shakeTimer = 0f;
_shakeTarget = Random.insideUnitSphere * GetShakeRadius();
}
}
public void StopOrganicShake()
{
_isShaking = false;
_shakeOffset = Vector3.zero;
}
/// <summary>
/// Syncs the ghost word's sibling index so depth sorting covers both the main word and its ghost trail.
/// Should be called right after SetSiblingIndex on the main word.
/// </summary>
public void SyncGhostSiblingIndex(int mainSiblingIndex)
{
if (_ghostTextComponent == null) return;
// Place ghost just before its main word so it renders underneath it.
int ghostIndex = Mathf.Max(0, mainSiblingIndex - 1);
_ghostTextComponent.transform.SetSiblingIndex(ghostIndex);
}
public void SetShakeSpeedMultiplier(float multiplier)
{
_shakeSpeedMultiplier = Mathf.Max(0f, multiplier);
}
public void SetTextScale(float targetScale, float duration = 0.3f)
{
if (_choiceTextComponent == null)
return;
TweenHelper.CancelTween(ref _textScaleTweenId);
_textScaleTweenId = TweenHelper.ScaleLocal(
_choiceTextComponent.gameObject,
Vector3.one * targetScale,
duration,
null,
LeanTweenType.easeOutQuad
);
}
public void ResetTextScale(float duration = 0.3f)
{
SetTextScale(_originalTextScale, duration);
}
public void HideGhostTrail()
{
if (_ghostTextComponent != null)
_ghostTextComponent.gameObject.SetActive(false);
}
public void ShowGhostTrail()
{
if (_ghostTextComponent != null)
_ghostTextComponent.gameObject.SetActive(true);
}
/// <summary>
/// Applies progressive visual feedback as player focuses on this choice.
/// Scales the choice, changes color, optionally moves toward player, and adjusts sorting order.
/// Progress ranges from 0 (unfocused) to 1 (fully focused).
/// </summary>
public void ApplyFocusFeedback(float progress)
{
if (!_positionCaptured)
{
// Subtract shake offset to get the clean base position, not the shaken one
_startLocalPos = transform.localPosition - _shakeOffset;
_positionCaptured = true;
LeanTween.cancel(gameObject);
StopOrganicShake();
DialogueAnimationSettings settings = GetAnimationSettings();
float textScaleMultiplier = settings != null ? settings.FocusedTextScaleMultiplier : 0.9f;
float textScaleDuration = settings != null ? settings.FocusedTextScaleDuration : 0.3f;
SetTextScale(_originalTextScale * textScaleMultiplier, textScaleDuration);
}
// transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress);
bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus();
if (shouldMoveToPlayer && PlayerController.Instance != null)
{
float distance = GetFocusDistanceFromPlayer();
Vector3 targetPos = PlayerController.Instance.transform.position
+ PlayerController.Instance.transform.forward * distance;
transform.position = Vector3.Lerp(transform.position, targetPos, Time.deltaTime * _focusLerpSpeed);
}
if (_choiceTextComponent != null)
{
_choiceTextComponent.color = Color.Lerp(Color.white, Color.red, progress);
if (_ghostTextComponent != null)
{
float ghostAlpha = GetGhostTrailAlpha();
Color ghostColor = Color.Lerp(Color.white, Color.red, progress);
ghostColor.a = ghostAlpha;
_ghostTextComponent.color = ghostColor;
}
}
if (_canvas != null && _canvas.sortingOrder != FOCUSED_SORTING_ORDER)
{
_canvas.sortingOrder = FOCUSED_SORTING_ORDER;
_canvas.overrideSorting = true;
}
}
private DialogueAnimationSettings GetAnimationSettings()
{
if (DialogueManager.Instance != null)
return DialogueManager.Instance.CurrentAnimationSettings;
return null;
}
private bool GetEnableGhostTrail()
{
if (_hasConfig) return _config.EnableGhostTrail;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null && settings.EnableGhostTrail;
}
private float GetGhostTrailAlpha()
{
if (_hasConfig) return _config.GhostTrailAlpha;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.GhostTrailAlpha : 0.3f;
}
private float GetGhostTrailDelay()
{
if (_hasConfig) return _config.GhostTrailDelay;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.GhostTrailDelay : 0.1f;
}
private bool GetEnableOrganicShake()
{
if (_hasConfig) return _config.EnableOrganicShake;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null && settings.EnableOrganicShake;
}
private float GetShakeRadius()
{
if (_hasConfig) return _config.ShakeRadius;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.ShakeRadius : 0.01f;
}
private float GetShakeSpeed()
{
if (_hasConfig) return _config.ShakeSpeed;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.ShakeSpeed : 1f;
}
private float GetShakeSmoothness()
{
if (_hasConfig) return _config.ShakeSmoothness;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.ShakeSmoothness : 5f;
}
private bool GetMoveTowardPlayerOnFocus()
{
if (_hasConfig) return _config.MoveTowardPlayerOnFocus;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings == null || settings.MoveTowardPlayerOnFocus;
}
private float GetFocusDistanceFromPlayer()
{
if (_hasConfig) return _config.FocusDistanceFromPlayer;
DialogueAnimationSettings settings = GetAnimationSettings();
return settings != null ? settings.FocusDistanceFromPlayer : _choiceDistanceFromPlayer;
}
/// <summary>
/// Initializes the choice button with dialogue data.
/// Sets up text, creates/configures collider based on text bounds, and caches initial state.
/// </summary>
public void Setup(DialogueChoiceData dialogueData, FloatingWordsSpawner.SpawnConfig config, bool disableFloatingEffects = false)
{
DialogueData = dialogueData;
_config = config;
_hasConfig = true;
_disableFloatingEffects = disableFloatingEffects;
PerformSetup();
}
private void PerformSetup()
{
if (_choiceTextComponent != null)
{
_choiceTextComponent.text = GetChoiceText();
_choiceTextComponent.ForceMeshUpdate();
AdjustTextSizeForContent();
}
_choiceCollider = GetComponent<BoxCollider>();
if (_choiceCollider == null)
{
_choiceCollider = gameObject.AddComponent<BoxCollider>();
_choiceCollider.isTrigger = true;
}
if (_choiceTextComponent != null)
{
Vector3 textSize = _choiceTextComponent.bounds.size;
_choiceCollider.size = textSize * _colliderSizeMultiplier;
_choiceCollider.center = _choiceTextComponent.rectTransform.localPosition;
}
_startLocalPos = transform.localPosition;
_originalScale = transform.localScale.x;
if (_choiceTextComponent != null)
_originalTextScale = _choiceTextComponent.transform.localScale.x;
LeanTween.cancel(gameObject);
SetupGhostTrail();
if (_ghostTextComponent != null)
_ghostTextComponent.text = GetChoiceText();
DialogueAnimationSettings settings = GetAnimationSettings();
if (settings != null && settings.EnableSketchFadeOnFocus && settings.SketchMaterial != null)
{
_sketchFadeEffect = gameObject.AddComponent<SketchFadeEffect>();
_sketchFadeEffect.Initialize(_choiceTextComponent, _ghostTextComponent, settings.SketchMaterial);
}
StartOrganicShake();
}
private void AdjustTextSizeForContent()
{
if (_choiceTextComponent == null) return;
string text = GetChoiceText();
if (string.IsNullOrEmpty(text)) return;
int wordCount = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries).Length;
float maxWidth = GetMaxWidthForWordCount(wordCount);
string longestWord = GetLongestWord(text);
Vector2 longestWordSize = _choiceTextComponent.GetPreferredValues(longestWord, float.MaxValue, 0f);
if (longestWordSize.x > maxWidth)
maxWidth = longestWordSize.x + 10f;
_choiceTextComponent.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, maxWidth);
_choiceTextComponent.ForceMeshUpdate();
Vector2 textSize = _choiceTextComponent.GetPreferredValues(text, maxWidth, 0f);
float finalHeight = Mathf.Max(textSize.y, _minTextHeight);
Vector2 newSize = new Vector2(maxWidth, finalHeight);
RectTransform parentRect = transform as RectTransform;
if (parentRect != null)
parentRect.sizeDelta = newSize;
ConfigureTextComponent(_choiceTextComponent, newSize);
if (_ghostTextComponent != null)
ConfigureTextComponent(_ghostTextComponent, newSize);
}
private void ConfigureTextComponent(TextMeshProUGUI textComponent, Vector2 size)
{
textComponent.textWrappingMode = TextWrappingModes.Normal;
textComponent.overflowMode = TextOverflowModes.Overflow;
textComponent.rectTransform.anchorMin = Vector2.one * 0.5f;
textComponent.rectTransform.anchorMax = Vector2.one * 0.5f;
textComponent.rectTransform.pivot = Vector2.one * 0.5f;
textComponent.rectTransform.anchoredPosition = Vector2.zero;
textComponent.rectTransform.sizeDelta = size;
textComponent.ForceMeshUpdate();
}
private string GetLongestWord(string text)
{
string[] words = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries);
string longest = "";
foreach (string word in words)
{
if (word.Length > longest.Length)
longest = word;
}
return longest;
}
private float GetMaxWidthForWordCount(int wordCount)
{
if (wordCount <= 2)
return _shortTextMaxWidth;
else if (wordCount <= 5)
return _mediumTextMaxWidth;
else if (wordCount <= 10)
return _longTextMaxWidth;
else
return _veryLongTextMaxWidth;
}
/// <summary>
/// Smoothly returns the choice to its original state after losing focus.
/// Animates position back to start, resets scale/color, and unlocks the focus system when complete.
/// </summary>
public void ResetFeedback(float duration)
{
LeanTween.cancel(gameObject);
TweenHelper.CancelTween(ref _textScaleTweenId);
StopOrganicShake();
ResetSketchFade();
// Reset position capture and restart shake immediately — don't rely on a LeanTween
// callback for this, since stale tween IDs (Domain Reload disabled) can silently
// drop callbacks, leaving some words permanently non-shaking.
_positionCaptured = false;
StartOrganicShake();
TweenHelper.MoveLocal(gameObject, _startLocalPos, duration, () => {
if (DialogueManager.Instance != null)
DialogueManager.Instance.UnlockFocus();
}, LeanTweenType.easeOutQuad);
transform.localScale = Vector3.one * _originalScale;
if (_choiceTextComponent != null)
{
_choiceTextComponent.transform.localScale = Vector3.one * _originalTextScale;
_choiceTextComponent.color = Color.white;
if (_ghostTextComponent != null)
{
float ghostAlpha = GetGhostTrailAlpha();
Color ghostColor = Color.white;
ghostColor.a = ghostAlpha;
_ghostTextComponent.color = ghostColor;
}
}
if (_canvas != null)
_canvas.sortingOrder = _originalSortingOrder;
}
public void ApplySketchFade()
{
DialogueAnimationSettings settings = GetAnimationSettings();
if (settings == null || !settings.EnableSketchFadeOnFocus || _sketchFadeEffect == null)
return;
_sketchFadeEffect.PlaySketchFadeEffect(
settings.SketchDuration,
settings.FadeOutDuration,
settings.FinalFadeAlpha
);
}
public void ResetSketchFade()
{
if (_sketchFadeEffect != null)
_sketchFadeEffect.ResetEffect();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: abd0f63a44b2272448eb41767c2ce44e
@@ -0,0 +1,35 @@
using UnityEngine;
using Unheimlich.Core;
namespace Unheimlich.Features.Dialogue.UI
{
public class CrosshairController : Singleton<CrosshairController>
{
[Header("References")]
[SerializeField] private GameObject _crosshairObject;
private bool _isVisible = true;
protected override void OnAwake()
{
if (_crosshairObject != null)
_crosshairObject.SetActive(true);
}
public void Show()
{
if (_crosshairObject != null)
_crosshairObject.SetActive(true);
_isVisible = true;
}
public void Hide()
{
if (_crosshairObject != null)
_crosshairObject.SetActive(false);
_isVisible = false;
}
public bool IsVisible => _isVisible;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9adc6263b2493341b02087fb5a00404
@@ -0,0 +1,265 @@
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using Unheimlich.Core;
using Unheimlich.Features.Player;
namespace Unheimlich.Features.Dialogue.UI
{
public class DialogueBlurEffectURP : Singleton<DialogueBlurEffectURP>
{
[Header("Volume Settings")]
[SerializeField] private Volume _globalVolume;
[Header("Depth of Field (Gaussian)")]
[SerializeField] private float _gaussianStart = 0f;
[SerializeField] private float _gaussianEnd = 15f;
[SerializeField] private float _maxGaussianRadius = 4f;
[Header("Brightness (Color Adjustments)")]
[SerializeField] private float _startPostExposure = 0f;
[SerializeField] private float _targetPostExposure = 0.3f;
[Header("Animation Settings")]
[SerializeField] private float _fadeDuration = 1.0f;
[SerializeField] private AnimationCurve _fadeCurve;
[Header("UI Configuration")]
[Tooltip("Layer(s) for dialogue (World Space UI) to render without blur")]
[SerializeField] private LayerMask _dialogueLayers;
[SerializeField] private bool _autoCreateDialogueCamera = true;
[Header("Hands Configuration")]
[Tooltip("Layer for player hands to render on top of dialogue")]
[SerializeField] private LayerMask _handsLayer;
[SerializeField] private bool _autoCreateHandsCamera = true;
private DepthOfField _dof;
private ColorAdjustments _color;
private VolumeProfile _profile;
private float _current = 0f;
private float _target = 0f;
private float _start = 0f;
private float _timer = 0f;
private bool _fading = false;
private Camera _baseCam;
private Camera _dialogueCam;
private Camera _handsCam;
protected override void OnAwake()
{
if (_fadeCurve == null || _fadeCurve.keys.Length == 0)
_fadeCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
if (_globalVolume == null)
{
Debug.LogError("[DialogueBlur] ERROR: Global Volume NOT ASSIGNED in inspector!");
enabled = false;
return;
}
if (_globalVolume.profile == null)
{
Debug.LogError("[DialogueBlur] ERROR: Global Volume has NO VolumeProfile assigned!");
enabled = false;
return;
}
_profile = _globalVolume.profile;
if (!_profile.TryGet(out _dof))
{
Debug.LogError("[DialogueBlur] ERROR: Depth Of Field NOT FOUND in Volume Profile!");
enabled = false;
return;
}
if (!_profile.TryGet(out _color))
{
Debug.LogWarning("[DialogueBlur] WARNING: Color Adjustments not found (not critical)");
}
_dof.mode.Override(DepthOfFieldMode.Gaussian);
_dof.gaussianStart.Override(_gaussianStart);
_dof.gaussianEnd.Override(_gaussianEnd);
_dof.gaussianMaxRadius.Override(0f);
_dof.active = false;
if (_color != null)
{
_color.postExposure.Override(_startPostExposure);
_color.active = false;
}
_globalVolume.weight = 0f;
_globalVolume.enabled = true;
}
void Start()
{
_baseCam = PlayerController.Instance.PlayerCamera;
if (_autoCreateDialogueCamera)
SetupDialogueOverlayCamera();
if (_autoCreateHandsCamera)
SetupHandsOverlayCamera();
}
void SetupDialogueOverlayCamera()
{
var baseData = _baseCam.GetComponent<UniversalAdditionalCameraData>();
if (baseData == null)
baseData = _baseCam.gameObject.AddComponent<UniversalAdditionalCameraData>();
baseData.renderType = CameraRenderType.Base;
baseData.renderPostProcessing = true;
if (_dialogueLayers.value == 0)
Debug.LogWarning("[DialogueBlur] WARNING: dialogueLayers is EMPTY! Ghost words will be blurred!");
_baseCam.cullingMask &= ~_dialogueLayers;
if (_handsLayer.value != 0)
_baseCam.cullingMask &= ~_handsLayer;
var go = new GameObject("Dialogue Camera (Overlay)");
go.transform.SetParent(_baseCam.transform);
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
_dialogueCam = go.AddComponent<Camera>();
_dialogueCam.CopyFrom(_baseCam);
_dialogueCam.clearFlags = CameraClearFlags.Depth;
_dialogueCam.cullingMask = _dialogueLayers;
var overlayData = go.AddComponent<UniversalAdditionalCameraData>();
overlayData.renderType = CameraRenderType.Overlay;
overlayData.renderPostProcessing = false;
baseData.cameraStack.Add(_dialogueCam);
}
void SetupHandsOverlayCamera()
{
if (_handsLayer.value == 0)
{
Debug.LogWarning("[DialogueBlur] WARNING: handsLayer is EMPTY! Hands camera will not be created.");
return;
}
var baseData = _baseCam.GetComponent<UniversalAdditionalCameraData>();
if (baseData == null)
{
Debug.LogError("[DialogueBlur] ERROR: Base camera has no UniversalAdditionalCameraData!");
return;
}
var go = new GameObject("Hands Camera (Overlay)");
go.transform.SetParent(_baseCam.transform);
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
_handsCam = go.AddComponent<Camera>();
_handsCam.CopyFrom(_baseCam);
_handsCam.clearFlags = CameraClearFlags.Depth;
_handsCam.cullingMask = _handsLayer;
var overlayData = go.AddComponent<UniversalAdditionalCameraData>();
overlayData.renderType = CameraRenderType.Overlay;
overlayData.renderPostProcessing = false;
baseData.cameraStack.Add(_handsCam);
}
void Update()
{
if (!_fading) return;
_timer += Time.deltaTime;
float t = Mathf.Clamp01(_timer / Mathf.Max(0.0001f, _fadeDuration));
float cv = _fadeCurve.Evaluate(t);
_current = Mathf.Lerp(_start, _target, cv);
ApplyEffects(_current);
if (t >= 1f)
{
_current = _target;
ApplyEffects(_current);
_fading = false;
}
}
void ApplyEffects(float s)
{
s = Mathf.Clamp01(s);
bool active = s > 0.01f;
_globalVolume.weight = s;
if (active)
{
_dof.active = true;
_dof.gaussianStart.value = _gaussianStart;
_dof.gaussianEnd.value = _gaussianEnd;
float radius = Mathf.Lerp(0f, _maxGaussianRadius, s);
_dof.gaussianMaxRadius.value = radius;
if (_color != null)
{
_color.active = true;
_color.postExposure.value = Mathf.Lerp(_startPostExposure, _targetPostExposure, s);
}
}
else
{
_dof.gaussianMaxRadius.value = 0f;
_dof.active = false;
if (_color != null)
{
_color.postExposure.value = _startPostExposure;
_color.active = false;
}
}
}
public void ShowBlur()
{
if (_dialogueCam == null && _autoCreateDialogueCamera)
{
if (_baseCam == null && PlayerController.Instance != null)
_baseCam = PlayerController.Instance.PlayerCamera;
if (_baseCam != null)
{
SetupDialogueOverlayCamera();
if (_autoCreateHandsCamera)
SetupHandsOverlayCamera();
}
else
Debug.LogWarning("[DialogueBlur] WARNING: Cannot create dialogue camera - PlayerCamera not ready!");
}
if (_fading && _target == 1f) return;
_start = _current;
_target = 1f;
_timer = 0f;
_fading = true;
}
public void HideBlur()
{
if (_fading && _target == 0f) return;
_start = _current;
_target = 0f;
_timer = 0f;
_fading = true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6ed4b22b4be6bbe49a67cbe552c633e1
@@ -0,0 +1,79 @@
using UnityEngine;
using TMPro;
using Unheimlich.Core;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue.UI
{
public class DialogueScreenUI : Singleton<DialogueScreenUI>
{
[Header("UI References")]
[SerializeField] private GameObject _dialoguePanel;
[SerializeField] private TextMeshProUGUI _speakerText;
[SerializeField] private TextMeshProUGUI _dialogueText;
[Header("Fade Settings")]
[SerializeField] private float _fadeOutDuration = 0.5f;
private CanvasGroup _canvasGroup;
protected override void OnAwake()
{
if (_dialoguePanel != null)
{
_canvasGroup = _dialoguePanel.GetComponent<CanvasGroup>();
if (_canvasGroup == null)
_canvasGroup = _dialoguePanel.AddComponent<CanvasGroup>();
}
}
public void Show(string speaker, string dialogue)
{
if (_speakerText != null)
_speakerText.text = speaker;
if (_dialogueText != null)
_dialogueText.text = dialogue;
if (_dialoguePanel != null)
{
_dialoguePanel.SetActive(true);
if (_canvasGroup != null)
{
LeanTween.cancel(_dialoguePanel);
_canvasGroup.alpha = 1f;
}
}
}
public void Hide()
{
if (_dialoguePanel != null)
_dialoguePanel.SetActive(false);
}
public void FadeOutAndHide(System.Action onComplete = null)
{
if (_canvasGroup != null && _dialoguePanel != null)
{
TweenHelper.FadeCanvasGroup(
_canvasGroup,
0f,
_fadeOutDuration,
() => {
Hide();
onComplete.SafeInvoke();
});
}
else
{
Hide();
onComplete.SafeInvoke();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bc4c44e24ba19844b822608a46bde428
@@ -0,0 +1,375 @@
using UnityEngine;
using System.Collections.Generic;
using TMPro;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Player;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue.UI
{
/// <summary>
/// Manages dialogue UI display and choice button spawning with configurable animations.
/// Supports multiple spawn modes, distance scaling, player following, and visual effects.
/// </summary>
public class DialogueUI : MonoBehaviour
{
[Header("Panels - World Mode")]
[SerializeField] private GameObject _dialoguePanel;
public GameObject ChoiceContainer;
[Header("Text Fields - World Mode")]
[SerializeField] private TextMeshProUGUI _speakerText;
[SerializeField] private TextMeshProUGUI _contentText;
[Header("Prefabs")]
public ChoiceFocusHandler ChoiceButtonPrefab;
[Header("Default Animation Settings")]
[SerializeField] private float _defaultRotationSpeed = 36f;
[Header("Fade Settings")]
[SerializeField] private float _fadeOutDuration = 0.5f;
[SerializeField] private CanvasGroup _dialogueCanvasGroup;
private DialogueAnimationSettings _currentAnimationSettings;
private Vector3 _currentRotationAxis = Vector3.forward;
private bool _shouldFollowPlayerPosition = false;
private bool _shouldFollowPlayerRotation = false;
private Transform _currentAnchor;
private Vector3 _baseScale;
private Vector3 _basePanelScale;
private List<ChoiceFocusHandler> _activeChoices = new List<ChoiceFocusHandler>();
private float _sortTimer = 0f;
private const float SORT_INTERVAL = 0.1f;
private DialogueDisplayMode _currentDisplayMode = DialogueDisplayMode.World;
private int _fadeCanvasTweenId = -1;
private int _rotationTweenId = -1;
private List<int> _floatingTweenIds = new List<int>();
private Camera _mainCamera;
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
private void Awake()
{
_mainCamera = Camera.main;
if (_dialogueCanvasGroup == null && _dialoguePanel != null)
{
_dialogueCanvasGroup = _dialoguePanel.GetComponent<CanvasGroup>();
if (_dialogueCanvasGroup == null)
_dialogueCanvasGroup = _dialoguePanel.AddComponent<CanvasGroup>();
}
if (_dialoguePanel != null)
{
_basePanelScale = _dialoguePanel.transform.localScale;
}
}
private void Update()
{
if (PlayerController.Instance == null || !ChoiceContainer.activeInHierarchy)
return;
if (_shouldFollowPlayerPosition)
ChoiceContainer.transform.position = PlayerController.Instance.transform.position;
if (_shouldFollowPlayerRotation)
{
float playerYRotation = PlayerController.Instance.transform.eulerAngles.y;
ChoiceContainer.transform.rotation = Quaternion.Euler(0f, playerYRotation, 0f);
}
if (_currentAnimationSettings != null && _currentAnimationSettings.EnableDistanceScaling && !_shouldFollowPlayerPosition)
{
ApplyDistanceScaling();
ApplyDistanceScalingToPanel();
}
SortChoicesByDistance();
}
public void ShowDialogueBox(string speaker, string content, DialogueDisplayMode displayMode = DialogueDisplayMode.World)
{
_currentDisplayMode = displayMode;
if (displayMode == DialogueDisplayMode.World)
{
if (DialogueScreenUI.Instance != null)
DialogueScreenUI.Instance.Hide();
_speakerText.text = speaker;
_contentText.text = content;
_dialoguePanel.SetActive(true);
if (_dialogueCanvasGroup != null)
{
TweenHelper.CancelTween(ref _fadeCanvasTweenId);
_dialogueCanvasGroup.alpha = 1f;
}
}
else
{
if (_dialoguePanel != null)
_dialoguePanel.SetActive(false);
if (DialogueScreenUI.Instance != null)
DialogueScreenUI.Instance.Show(speaker, content);
else
Debug.LogWarning("[DialogueUI] DialogueScreenUI.Instance is null! Make sure DialogueScreenUI component exists in the scene.");
}
}
public void HideDialogueBox()
{
if (_dialoguePanel != null)
_dialoguePanel.SetActive(false);
if (DialogueScreenUI.Instance != null)
DialogueScreenUI.Instance.Hide();
ChoiceContainer.SetActive(false);
HideChoices();
}
public void FadeOutAndHide(System.Action onComplete = null)
{
if (_currentDisplayMode == DialogueDisplayMode.World)
{
if (_dialogueCanvasGroup != null && _dialoguePanel != null)
{
TweenHelper.CancelTween(ref _fadeCanvasTweenId);
_fadeCanvasTweenId = TweenHelper.FadeCanvasGroup(
_dialogueCanvasGroup,
0f,
_fadeOutDuration,
() =>
{
HideDialogueBox();
onComplete?.Invoke();
});
}
else
{
HideDialogueBox();
onComplete?.Invoke();
}
}
else
{
if (DialogueScreenUI.Instance != null)
{
DialogueScreenUI.Instance.FadeOutAndHide(() =>
{
HideChoices();
ChoiceContainer.SetActive(false);
onComplete?.Invoke();
});
}
else
{
HideDialogueBox();
onComplete?.Invoke();
}
}
}
public bool AreChoicesVisible()
{
return ChoiceContainer.transform.childCount > 0;
}
public void ShowChoices(List<DialogueChoiceData> nodeChoices, Transform anchorTransform, DialogueAnimationSettings animationSettings = null)
{
HideChoices();
_currentAnimationSettings = animationSettings;
_currentAnchor = anchorTransform;
if (anchorTransform != null)
{
ChoiceContainer.transform.SetParent(anchorTransform);
bool followPosition = animationSettings != null && animationSettings.FollowPlayerPosition;
bool followRotation = animationSettings != null && animationSettings.FollowPlayerRotation;
_shouldFollowPlayerPosition = followPosition;
_shouldFollowPlayerRotation = followRotation;
if (followPosition && PlayerController.Instance != null)
{
Vector3 playerWorldPos = PlayerController.Instance.transform.position;
ChoiceContainer.transform.position = playerWorldPos;
}
else
ChoiceContainer.transform.localPosition = Vector3.zero;
if (followRotation && PlayerController.Instance != null)
{
float playerYRotation = PlayerController.Instance.transform.eulerAngles.y;
ChoiceContainer.transform.rotation = Quaternion.Euler(0f, playerYRotation, 0f);
}
else
ChoiceContainer.transform.localRotation = Quaternion.identity;
}
ChoiceContainer.SetActive(true);
ChoiceContainer.transform.localScale = Vector3.one;
_baseScale = Vector3.one;
int numChoices = nodeChoices.Count;
if (numChoices == 0) return;
SpawnChoices(nodeChoices, animationSettings);
if (animationSettings != null && animationSettings.EnableDistanceScaling && !_shouldFollowPlayerPosition)
{
ApplyDistanceScaling();
ApplyDistanceScalingToPanel();
}
bool shouldRotate = animationSettings == null || animationSettings.EnableRotation;
if (shouldRotate)
{
float rotationSpeed = animationSettings != null ? animationSettings.NormalRotationSpeed : _defaultRotationSpeed;
RotationAnimation(rotationSpeed);
}
}
private void SpawnChoices(List<DialogueChoiceData> nodeChoices, DialogueAnimationSettings settings)
{
_currentRotationAxis = GetRotationAxisVector(settings != null ? settings.RotationAxis : RotationAxis.Forward);
if (settings == null) return;
Vector3[] originalPositions;
List<ChoiceFocusHandler> spawnedChoices = FloatingWordsSpawner.SpawnByMode(
nodeChoices,
ChoiceButtonPrefab,
ChoiceContainer.transform,
settings,
out originalPositions
);
for (int i = 0; i < spawnedChoices.Count; i++)
{
_activeChoices.Add(spawnedChoices[i]);
if (settings.EnableFloating)
StartFloatingAnimation(spawnedChoices[i].gameObject, i);
}
}
private void StartFloatingAnimation(GameObject choice, int index)
{
if (_currentAnimationSettings == null) return;
float amplitude = _currentAnimationSettings.FloatingAmplitude;
float duration = _currentAnimationSettings.FloatingDuration;
float delay = index * 0.2f;
Vector3 startPos = choice.transform.localPosition;
int tweenId = TweenHelper.FloatAnimation(choice, startPos, amplitude, duration, delay, Axis.Y);
_floatingTweenIds.Add(tweenId);
}
public void RotationAnimation(float degreesPerSecond = 60f)
{
TweenHelper.CancelTween(ref _rotationTweenId);
_rotationTweenId = TweenHelper.RotateAround(ChoiceContainer, _currentRotationAxis, degreesPerSecond);
}
/// <summary>
/// Dynamically scales the choice container based on distance from player to anchor.
/// Provides visual feedback for spatial awareness in 3D dialogue interactions.
/// </summary>
private void ApplyDistanceScaling()
{
if (_currentAnchor == null || PlayerController.Instance == null)
return;
float distance = Vector3.Distance(_currentAnchor.position, PlayerController.Instance.transform.position);
float normalizedDistance = Mathf.InverseLerp(_currentAnimationSettings.MinDistance, _currentAnimationSettings.MaxDistance, distance);
float finalScale = Mathf.Lerp(_currentAnimationSettings.MinScaleMultiplier, _currentAnimationSettings.MaxScaleMultiplier, normalizedDistance);
ChoiceContainer.transform.localScale = _baseScale * finalScale;
}
private void ApplyDistanceScalingToPanel()
{
if (_currentAnchor == null || PlayerController.Instance == null || _dialoguePanel == null)
return;
float distance = Vector3.Distance(_currentAnchor.position, PlayerController.Instance.transform.position);
float normalizedDistance = Mathf.InverseLerp(_currentAnimationSettings.MinDistance, _currentAnimationSettings.MaxDistance, distance);
float finalScale = Mathf.Lerp(_currentAnimationSettings.MinScaleMultiplier, _currentAnimationSettings.MaxScaleMultiplier, normalizedDistance);
_dialoguePanel.transform.localScale = _basePanelScale * finalScale;
}
private Vector3 GetRotationAxisVector(RotationAxis axis)
{
switch (axis)
{
case RotationAxis.Forward:
return Vector3.forward;
case RotationAxis.Up:
return Vector3.up;
case RotationAxis.Right:
return Vector3.right;
default:
return Vector3.forward;
}
}
public void HideChoices()
{
_shouldFollowPlayerPosition = false;
_shouldFollowPlayerRotation = false;
_currentAnchor = null;
_currentAnimationSettings = null;
_activeChoices.Clear();
TweenHelper.CancelTween(ref _rotationTweenId);
foreach (int tweenId in _floatingTweenIds)
{
int id = tweenId;
TweenHelper.CancelTween(ref id);
}
_floatingTweenIds.Clear();
foreach (Transform child in ChoiceContainer.transform)
{
Destroy(child.gameObject);
}
}
/// <summary>
/// Sorts choice buttons by distance to player camera.
/// Closer choices are rendered on top by being placed last in hierarchy (higher sibling index).
/// Uses interval-based sorting to reduce performance overhead.
/// </summary>
private void SortChoicesByDistance()
{
if (_activeChoices.Count <= 1 || PlayerController.Instance == null)
return;
_sortTimer += Time.deltaTime;
if (_sortTimer < SORT_INTERVAL)
return;
_sortTimer = 0f;
FloatingWordsSpawner.SortByDistance(_activeChoices, _mainCamera);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 90f07eb59fe4c5b49b180cceacb3f24c
@@ -0,0 +1,531 @@
using UnityEngine;
using TMPro;
using System.Collections;
using System.Collections.Generic;
using Unheimlich.Features.Hallucination;
namespace Unheimlich.Features.Dialogue.UI
{
/// <summary>
/// Non-interactive floating word/phrase for world-space storytelling.
///
/// Changes from previous version:
/// - Registers/unregisters itself with HallucinationLabelRegistry on enable/disable
/// so the HallucinationManager can drive baseline behaviour without FindObjectsOfType.
/// - Exposes SetShakeParams() and SetGhostTrail() for runtime overrides.
/// - ApplyBaseline() / ApplyOverride() drive the two-layer system.
/// - One-shot trigger distance is now explicit and independent from the visual near-fade.
/// - One-shot fade-out runs independently from the distance-based alpha so it is never overwritten.
/// </summary>
public class FloatingLabel : MonoBehaviour
{
// ─── Inspector ─────────────────────────────────────────────────────────
[Header("Text")]
[Tooltip("Leave empty to keep text set directly on the TextMeshPro component.")]
[SerializeField] [TextArea] private string _overrideText;
[SerializeField] private TextMeshPro _textComponent;
[Header("Visibility")]
[SerializeField] private float _maxVisibleDistance = 10f;
[SerializeField] private float _fadeRange = 1.5f;
[Header("Min Distance")]
[SerializeField] private float _minDistance = 0.5f;
[SerializeField] private float _nearFadeRange = 0.8f;
[Header("Adaptive Scale")]
[SerializeField] private bool _enableAdaptiveScale = true;
[SerializeField] private float _referenceDistance = 3f;
[SerializeField] private float _minScaleFactor = 0.2f;
[SerializeField] private float _maxScaleFactor = 3f;
[Header("Organic Shake — Baseline")]
[Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")]
[SerializeField] private bool _enableOrganicShake = false;
[SerializeField] private float _shakeRadius = 0.05f;
[SerializeField] private float _shakeSpeed = 1.5f;
[SerializeField] private float _shakeSmoothness = 2f;
[Header("Ghost Trail — Baseline")]
[Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")]
[SerializeField] private bool _enableGhostTrail = false;
[SerializeField] [Range(0f, 1f)] private float _ghostAlpha = 0.3f;
[SerializeField] private float _ghostDelay = 0.15f;
[SerializeField] private float _ghostDepthOffset = 0.02f;
[Header("Camera Attraction (Optional)")]
[SerializeField] private bool _enableCameraAttraction = false;
[SerializeField] [Range(1f, 45f)] private float _attractionAngle = 10f;
[SerializeField] private float _attractionMaxOffset = 0.3f;
[SerializeField] private float _attractionSmoothness = 4f;
[Header("One-Shot (Optional)")]
[Tooltip("Enable one-shot behaviour: the label permanently disappears after the player gets close enough.")]
[SerializeField] private bool _enableOneShot = false;
[Tooltip("If true, the one-shot resets when the player walks far away (outside far-fade zone) without having triggered it.")]
[SerializeField] private bool _isRetriggerableFar = false;
[Tooltip("Distance at which the one-shot triggers, completely independent from the visual near-fade.")]
[SerializeField] private float _oneShotTriggerDistance = 0.5f;
[Tooltip("Duration of the fade-out once the one-shot is triggered.")]
[SerializeField] private float _fadeOutDuration = 0.5f;
[Header("Reappear Fade")]
[SerializeField] private float _fadeInDuration = 0.5f;
[Header("Performance")]
[SerializeField] private float _cullCheckInterval = 0.4f;
// ─── Runtime state ─────────────────────────────────────────────────────
private Camera _camera;
private Vector3 _localHomePosition;
private Vector3 _homePosition;
private Vector3 _baseScale;
// Culling & fade-in
private bool _isActive = true;
private float _cullTimer;
private float _fadeInAlpha = 1f;
// Shake — live values driven by baseline or override
private bool _isShaking;
private Vector3 _shakeOffset;
private Vector3 _shakeTarget;
private float _shakeTimer;
private float _liveShakeRadius;
private float _liveShakeSpeed;
private float _liveShakeSmoothness;
// Ghost trail — live values
private bool _liveGhostEnabled;
private float _liveGhostAlpha;
private TextMeshPro _ghostText;
private Queue<Vector3> _positionHistory = new();
private float _historyTimer;
private const float HISTORY_SAMPLE_RATE = 0.016f;
// Camera attraction
private Vector3 _attractionOffset;
// One-shot
private bool _hasBeenSeen;
private bool _permanentlyHidden;
private float _closestDistanceReached = float.MaxValue;
// One-shot fade-out — runs independently from the distance alpha
private bool _isFadingOut = false;
private float _fadeOutTimer = 0f;
private float _fadeOutStartAlpha = 1f;
// Override coroutine
private Coroutine _overrideCoroutine;
// Baseline snapshot (so revert after override is accurate)
private float _baselineShakeRadius;
private float _baselineShakeSpeed;
private float _baselineShakeSmoothness;
private bool _baselineGhostEnabled;
private float _baselineGhostAlpha;
// ─── Unity ─────────────────────────────────────────────────────────────
private void Awake()
{
_camera = Camera.main;
if (_textComponent == null)
_textComponent = GetComponent<TextMeshPro>();
// Initialise live values from inspector defaults
_liveShakeRadius = _shakeRadius;
_liveShakeSpeed = _shakeSpeed;
_liveShakeSmoothness = _shakeSmoothness;
_liveGhostEnabled = _enableGhostTrail;
_liveGhostAlpha = _ghostAlpha;
}
private void Start()
{
_localHomePosition = transform.localPosition;
_baseScale = transform.localScale;
if (_textComponent != null && !string.IsNullOrEmpty(_overrideText))
_textComponent.text = _overrideText;
SaveBaselineSnapshot();
SetupGhostTrail();
if (_enableOrganicShake)
StartShakeInternal();
}
private void OnEnable() => HallucinationLabelRegistry.Register(this);
private void OnDisable() => HallucinationLabelRegistry.Unregister(this);
private void Update()
{
if (_permanentlyHidden) return;
if (_camera == null) { _camera = Camera.main; return; }
// ── Distance cull ──────────────────────────────────────────────────
_cullTimer += Time.deltaTime;
if (_cullTimer >= _cullCheckInterval)
{
_cullTimer = 0f;
bool shouldBeActive = Vector3.Distance(_camera.transform.position, transform.position)
<= _maxVisibleDistance + _fadeRange;
if (shouldBeActive != _isActive)
{
_isActive = shouldBeActive;
_textComponent.enabled = _isActive;
if (_ghostText != null) _ghostText.enabled = _isActive && _liveGhostEnabled;
if (_isActive) _fadeInAlpha = 0f;
}
}
if (!_isActive) return;
UpdatePosition();
float dist = Vector3.Distance(_camera.transform.position, transform.position);
// ── One-shot ───────────────────────────────────────────────────────
if (_enableOneShot)
{
// Track the closest the player has ever been since the label was seen
if (_hasBeenSeen && dist < _closestDistanceReached)
_closestDistanceReached = dist;
// Mark as seen as soon as the player enters the visible range
if (!_hasBeenSeen && dist <= _maxVisibleDistance)
{
_hasBeenSeen = true;
_closestDistanceReached = dist;
}
// Fade-out is running — drive alpha independently, skip normal alpha pipeline
if (_isFadingOut)
{
_fadeOutTimer += Time.deltaTime;
float t = 1f - Mathf.Clamp01(_fadeOutTimer / _fadeOutDuration);
float a = _fadeOutStartAlpha * t;
SetTextAlpha(_textComponent, a);
if (_ghostText != null) SetTextAlpha(_ghostText, a * _liveGhostAlpha);
if (_fadeOutTimer >= _fadeOutDuration)
{
_permanentlyHidden = true;
_isFadingOut = false;
_textComponent.enabled = false;
if (_ghostText != null) _ghostText.enabled = false;
}
return;
}
if (_hasBeenSeen)
{
// Trigger: player reached the explicit trigger distance at some point
bool hasTriggered = _closestDistanceReached <= _oneShotTriggerDistance;
// Reset: player walked far away without ever triggering
bool isFarNow = dist > _maxVisibleDistance - _fadeRange;
if (hasTriggered)
{
_isFadingOut = true;
_fadeOutTimer = 0f;
_fadeOutStartAlpha = ComputeAlpha(dist) * _fadeInAlpha;
_closestDistanceReached = float.MaxValue;
return;
}
if (_isRetriggerableFar && isFarNow)
{
_hasBeenSeen = false;
_closestDistanceReached = float.MaxValue;
}
}
}
// ── Normal alpha pipeline ──────────────────────────────────────────
float alpha = ComputeAlpha(dist) * _fadeInAlpha;
UpdateCameraAttraction(alpha);
ApplyAlpha(alpha);
UpdateAdaptiveScale();
if (_isShaking) UpdateShake();
UpdateGhostTrail();
}
// ─── Public API — two-layer system ─────────────────────────────────────
/// <summary>
/// Pushes sanity-driven baseline values. Ignored while an override is active.
/// Called every frame by HallucinationManager for labels within range.
/// </summary>
public void ApplyBaseline(float shakeRadius, float shakeSpeed, float shakeSmoothness,
bool ghostEnabled, float ghostAlpha)
{
if (_overrideCoroutine != null) return;
SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness);
SetGhostTrail(ghostEnabled, ghostAlpha);
// Keep snapshot fresh so revert after override is correct
_baselineShakeRadius = shakeRadius;
_baselineShakeSpeed = shakeSpeed;
_baselineShakeSmoothness = shakeSmoothness;
_baselineGhostEnabled = ghostEnabled;
_baselineGhostAlpha = ghostAlpha;
}
/// <summary>
/// Applies a timed override then reverts to the last baseline snapshot.
/// Called by HallucinationEvent at trigger time.
/// </summary>
public void ApplyOverride(float shakeRadius, float shakeSpeed, float shakeSmoothness,
bool ghostEnabled, float ghostAlpha,
float duration)
{
if (_overrideCoroutine != null) StopCoroutine(_overrideCoroutine);
_overrideCoroutine = StartCoroutine(OverrideRoutine(
shakeRadius, shakeSpeed, shakeSmoothness,
ghostEnabled, ghostAlpha, duration));
}
private IEnumerator OverrideRoutine(float shakeRadius, float shakeSpeed, float shakeSmoothness,
bool ghostEnabled, float ghostAlpha, float duration)
{
SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness);
SetGhostTrail(ghostEnabled, ghostAlpha);
yield return new WaitForSeconds(duration);
SetShakeParams(_baselineShakeRadius, _baselineShakeSpeed, _baselineShakeSmoothness);
SetGhostTrail(_baselineGhostEnabled, _baselineGhostAlpha);
_overrideCoroutine = null;
}
// ─── Low-level setters (public for editor tooling / direct calls) ───────
public void SetShakeParams(float radius, float speed, float smoothness)
{
_liveShakeRadius = radius;
_liveShakeSpeed = speed;
_liveShakeSmoothness = smoothness;
if (radius > 0f && !_isShaking) StartShakeInternal();
else if (radius <= 0f) _isShaking = false;
}
public void SetGhostTrail(bool enabled, float alpha)
{
_liveGhostEnabled = enabled;
_liveGhostAlpha = alpha;
if (_ghostText != null) _ghostText.enabled = _isActive && enabled;
}
// ─── Existing API ──────────────────────────────────────────────────────
public void SetText(string text)
{
_overrideText = text;
if (_textComponent != null) _textComponent.text = text;
if (_ghostText != null) _ghostText.text = text;
}
public void SetHomePosition(Vector3 worldPos)
{
_homePosition = worldPos;
transform.position = worldPos;
}
public void ResetOneShot()
{
_hasBeenSeen = false;
_permanentlyHidden = false;
_isFadingOut = false;
_fadeOutTimer = 0f;
_fadeOutStartAlpha = 1f;
_closestDistanceReached = float.MaxValue;
if (_textComponent != null) _textComponent.enabled = true;
if (_ghostText != null) _ghostText.enabled = _liveGhostEnabled;
_fadeInAlpha = 0f;
}
// ─── Internals ─────────────────────────────────────────────────────────
private void SaveBaselineSnapshot()
{
_baselineShakeRadius = _shakeRadius;
_baselineShakeSpeed = _shakeSpeed;
_baselineShakeSmoothness = _shakeSmoothness;
_baselineGhostEnabled = _enableGhostTrail;
_baselineGhostAlpha = _ghostAlpha;
}
private void UpdatePosition()
{
if (transform.parent != null)
_homePosition = transform.parent.TransformPoint(_localHomePosition);
Vector3 toHome = _homePosition - _camera.transform.position;
float dist = toHome.magnitude;
Vector3 basePos = (dist < _minDistance && dist > 0.001f)
? _camera.transform.position + toHome.normalized * _minDistance
: _homePosition + _shakeOffset;
transform.position = basePos + _attractionOffset;
}
private void ApplyAlpha(float alpha)
{
if (_textComponent == null) return;
if (_fadeInAlpha < 1f)
_fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f,
Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f));
SetTextAlpha(_textComponent, alpha);
if (_ghostText != null && _liveGhostEnabled)
SetTextAlpha(_ghostText, alpha * _liveGhostAlpha);
}
private float ComputeAlpha(float dist)
{
if (dist >= _maxVisibleDistance) return 0f;
float farFadeStart = _maxVisibleDistance - _fadeRange;
if (dist > farFadeStart && _fadeRange > 0f)
return 1f - (dist - farFadeStart) / _fadeRange;
float nearFadeEnd = _minDistance + _nearFadeRange;
if (dist < nearFadeEnd && _nearFadeRange > 0f)
return Mathf.Clamp01((dist - _minDistance) / _nearFadeRange);
return 1f;
}
private static void SetTextAlpha(TextMeshPro text, float alpha)
{
Color c = text.color;
c.a = alpha;
text.color = c;
}
private void UpdateAdaptiveScale()
{
if (!_enableAdaptiveScale) return;
float dist = Vector3.Distance(_camera.transform.position, transform.position);
float scaleFactor = Mathf.Clamp(dist / Mathf.Max(_referenceDistance, 0.001f),
_minScaleFactor, _maxScaleFactor);
transform.localScale = _baseScale * scaleFactor;
if (_ghostText != null) _ghostText.transform.localScale = transform.localScale;
}
private void UpdateCameraAttraction(float currentAlpha)
{
if (!_enableCameraAttraction) { _attractionOffset = Vector3.zero; return; }
Vector3 toLabel = _homePosition - _camera.transform.position;
float dist = toLabel.magnitude;
if (dist < 0.001f) { _attractionOffset = Vector3.zero; return; }
float angleDeg = Vector3.Angle(_camera.transform.forward, toLabel.normalized);
float t = Mathf.Clamp01(1f - angleDeg / Mathf.Max(_attractionAngle, 0.001f));
float strength = t * t * currentAlpha;
Vector3 labelOnCenterRay = _camera.transform.position + _camera.transform.forward * dist;
Vector3 targetOffset = (labelOnCenterRay - _homePosition) * strength;
if (targetOffset.magnitude > _attractionMaxOffset)
targetOffset = targetOffset.normalized * _attractionMaxOffset;
_attractionOffset = Vector3.Lerp(_attractionOffset, targetOffset,
Time.deltaTime * _attractionSmoothness);
}
private void StartShakeInternal()
{
_isShaking = true;
_shakeTimer = 0f;
_shakeTarget = RandomShakeTarget();
}
private Vector3 RandomShakeTarget()
{
float scaledRadius = _liveShakeRadius * transform.localScale.x;
Vector2 circle = Random.insideUnitCircle * scaledRadius;
return new Vector3(circle.x, circle.y, 0f);
}
private void UpdateShake()
{
_shakeTimer += Time.deltaTime * _liveShakeSpeed;
if (_shakeTimer >= 1f) { _shakeTimer = 0f; _shakeTarget = RandomShakeTarget(); }
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * _liveShakeSmoothness);
}
private void SetupGhostTrail()
{
// Ghost GO is always created when the feature might be enabled at runtime
if (_textComponent == null) return;
GameObject ghostObj = new GameObject($"Ghost_{gameObject.name}");
ghostObj.transform.SetParent(transform.parent, false);
ghostObj.transform.position = transform.position;
ghostObj.transform.rotation = transform.rotation;
ghostObj.transform.localScale = transform.localScale;
ghostObj.layer = gameObject.layer;
_ghostText = ghostObj.AddComponent<TextMeshPro>();
_ghostText.font = _textComponent.font;
_ghostText.fontSize = _textComponent.fontSize;
_ghostText.alignment = _textComponent.alignment;
_ghostText.text = _textComponent.text;
_ghostText.color = new Color(_textComponent.color.r, _textComponent.color.g,
_textComponent.color.b, _liveGhostAlpha);
MeshRenderer mainRenderer = _textComponent.GetComponent<MeshRenderer>();
MeshRenderer ghostRenderer = _ghostText.GetComponent<MeshRenderer>();
if (mainRenderer != null && ghostRenderer != null)
{
ghostRenderer.sortingLayerID = mainRenderer.sortingLayerID;
ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1;
}
ghostObj.AddComponent<BillboardUI>();
// Start hidden — the manager or an override will enable it when needed
_ghostText.enabled = _liveGhostEnabled;
}
private void UpdateGhostTrail()
{
if (!_liveGhostEnabled || _ghostText == null) return;
_historyTimer += Time.deltaTime;
if (_historyTimer >= HISTORY_SAMPLE_RATE)
{
_positionHistory.Enqueue(transform.position);
_historyTimer = 0f;
int maxSize = Mathf.CeilToInt(_ghostDelay / HISTORY_SAMPLE_RATE);
while (_positionHistory.Count > maxSize) _positionHistory.Dequeue();
}
Vector3 ghostPos = _positionHistory.Count > 0 ? _positionHistory.Peek() : transform.position;
_ghostText.transform.position = ghostPos + _camera.transform.forward * _ghostDepthOffset;
_ghostText.text = _textComponent.text;
_ghostText.fontSize = _textComponent.fontSize;
}
#if UNITY_EDITOR
private void OnValidate()
{
if (_textComponent != null && !string.IsNullOrEmpty(_overrideText))
_textComponent.text = _overrideText;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f7a1b2f9cb1305a4993ea145d8106942
@@ -0,0 +1,439 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Player;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue.UI
{
public static class FloatingWordsSpawner
{
private const float ROTATOR_SCALE = 0.01f;
/// <summary>
/// Internal configuration struct to unify settings from different sources.
/// </summary>
public struct SpawnConfig
{
public float SpawnRadius;
public float SpawnHeightOffset;
public RotationAxis RotationAxis;
public bool EnableOrganicShake;
public float ForwardOffset;
public float TiltAngle;
public float RotationSpeed;
public bool EnableRotation;
public ChoiceSpawnMode SpawnMode;
public bool EnableFloating;
public float MergeDuration;
public float MergeScalePulse;
public bool EnableGhostTrail;
public float GhostTrailAlpha;
public float GhostTrailDelay;
public float ShakeRadius;
public float ShakeSpeed;
public float ShakeSmoothness;
public bool MoveTowardPlayerOnFocus;
public float FocusDistanceFromPlayer;
public bool DisableFloatingEffects;
public float DefaultScaleMultiplier;
public bool EnableScaleVariation;
public float MinScaleVariation;
public float MaxScaleVariation;
}
private static SpawnConfig GetConfig(ObjectDescriptionSettings settings)
{
return new SpawnConfig
{
SpawnRadius = settings.SpawnRadius,
SpawnHeightOffset = settings.SpawnHeightOffset,
RotationAxis = settings.RotationAxis,
EnableOrganicShake = settings.EnableOrganicShake,
ForwardOffset = settings.ForwardOffset,
TiltAngle = settings.TiltAngle,
RotationSpeed = settings.RotationSpeed,
EnableRotation = settings.EnableRotation,
SpawnMode = settings.SpawnMode,
EnableFloating = settings.EnableFloating,
MergeDuration = settings.MergeDuration,
MergeScalePulse = settings.MergeScalePulse,
EnableGhostTrail = settings.EnableGhostTrail,
GhostTrailAlpha = settings.GhostTrailAlpha,
GhostTrailDelay = settings.GhostTrailDelay,
ShakeRadius = settings.ShakeRadius,
ShakeSpeed = settings.ShakeSpeed,
ShakeSmoothness = settings.ShakeSmoothness,
MoveTowardPlayerOnFocus = false,
FocusDistanceFromPlayer = 1f,
DisableFloatingEffects = true,
DefaultScaleMultiplier = 1f
};
}
private static SpawnConfig GetConfig(DialogueAnimationSettings settings)
{
return new SpawnConfig
{
SpawnRadius = settings.SpawnRadius,
SpawnHeightOffset = settings.SpawnHeightOffset,
RotationAxis = settings.RotationAxis,
EnableOrganicShake = settings.EnableOrganicShake,
ForwardOffset = 0f,
TiltAngle = 0f,
RotationSpeed = settings.NormalRotationSpeed,
EnableRotation = settings.EnableRotation,
SpawnMode = settings.SpawnMode,
EnableFloating = settings.EnableFloating,
MergeDuration = 0.3f,
MergeScalePulse = 1.2f,
EnableGhostTrail = settings.EnableGhostTrail,
GhostTrailAlpha = settings.GhostTrailAlpha,
GhostTrailDelay = settings.GhostTrailDelay,
ShakeRadius = settings.ShakeRadius,
ShakeSpeed = settings.ShakeSpeed,
ShakeSmoothness = settings.ShakeSmoothness,
MoveTowardPlayerOnFocus = settings.MoveTowardPlayerOnFocus,
FocusDistanceFromPlayer = settings.FocusDistanceFromPlayer,
DisableFloatingEffects = false,
DefaultScaleMultiplier = settings.DefaultScaleMultiplier,
EnableScaleVariation = settings.EnableScaleVariation,
MinScaleVariation = settings.MinScaleVariation,
MaxScaleVariation = settings.MaxScaleVariation
};
}
public static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> nodeChoices, ChoiceFocusHandler prefab, Transform container, DialogueAnimationSettings settings, out Vector3[] originalPositions)
{
return SpawnByMode(nodeChoices, prefab, container, GetConfig(settings), out originalPositions);
}
public static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform container, ObjectDescriptionSettings settings, out Vector3[] originalPositions)
{
return SpawnByMode(choices, prefab, container, GetConfig(settings), out originalPositions);
}
private static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> nodeChoices, ChoiceFocusHandler prefab, Transform container, SpawnConfig config, out Vector3[] originalPositions)
{
switch (config.SpawnMode)
{
case ChoiceSpawnMode.AroundAnchor:
return SpawnWordsInCircle(nodeChoices, prefab, container, config, out originalPositions);
case ChoiceSpawnMode.AtAnchorPosition:
originalPositions = new Vector3[0];
return SpawnAtAnchor(nodeChoices, prefab, container, config);
case ChoiceSpawnMode.InFrontOfPlayer:
originalPositions = new Vector3[0];
return SpawnInFrontOfPlayer(nodeChoices, prefab, container, config);
case ChoiceSpawnMode.CircleAroundPlayer:
return SpawnAroundPlayer(nodeChoices, prefab, container, config, out originalPositions);
default:
return SpawnWordsInCircle(nodeChoices, prefab, container, config, out originalPositions);
}
}
private static List<ChoiceFocusHandler> SpawnWordsInCircle(List<DialogueChoiceData> choices, ChoiceFocusHandler wordPrefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions)
{
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
originalPositions = new Vector3[choices.Count];
float angleStep = 360f / choices.Count;
for (int i = 0; i < choices.Count; i++)
{
float angle = i * angleStep * Mathf.Deg2Rad;
Vector3 localOffset = CalculateCircularPosition(angle, config.SpawnRadius, config.RotationAxis, config.SpawnHeightOffset);
originalPositions[i] = localOffset;
ChoiceFocusHandler word = Object.Instantiate(wordPrefab, parent);
word.transform.localPosition = localOffset;
word.transform.localRotation = Quaternion.identity;
float scaleMultiplier = config.DefaultScaleMultiplier;
if (config.EnableScaleVariation)
{
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
}
word.transform.localScale = Vector3.one * scaleMultiplier;
CanvasGroup canvasGroup = word.GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = word.gameObject.AddComponent<CanvasGroup>();
canvasGroup.alpha = 1f;
word.Setup(choices[i], config, config.DisableFloatingEffects);
if (config.EnableOrganicShake) word.StartOrganicShake();
words.Add(word);
}
return words;
}
private static List<ChoiceFocusHandler> SpawnAtAnchor(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config)
{
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
for (int i = 0; i < choices.Count; i++)
{
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
word.transform.localPosition = Vector3.up * i * 50f;
word.transform.localRotation = Quaternion.identity;
float scaleMultiplier = config.DefaultScaleMultiplier;
if (config.EnableScaleVariation)
{
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
}
word.transform.localScale = Vector3.one * scaleMultiplier;
word.Setup(choices[i], config);
words.Add(word);
}
return words;
}
private static List<ChoiceFocusHandler> SpawnInFrontOfPlayer(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config)
{
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
if (PlayerController.Instance == null) return words;
Vector3 playerForward = PlayerController.Instance.transform.forward;
for (int i = 0; i < choices.Count; i++)
{
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
Vector3 offset = playerForward * 2f + Vector3.up * (i - choices.Count / 2f) * (config.SpawnRadius / choices.Count / 100f);
word.transform.position = PlayerController.Instance.transform.position + offset;
word.transform.LookAt(PlayerController.Instance.transform);
float scaleMultiplier = config.DefaultScaleMultiplier;
if (config.EnableScaleVariation)
{
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
}
word.transform.localScale = Vector3.one * scaleMultiplier;
word.Setup(choices[i], config);
words.Add(word);
}
return words;
}
private static List<ChoiceFocusHandler> SpawnAroundPlayer(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions)
{
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
originalPositions = new Vector3[choices.Count];
if (PlayerController.Instance == null) return words;
parent.position = PlayerController.Instance.transform.position;
parent.rotation = Quaternion.identity;
float angleStep = 360f / choices.Count;
for (int i = 0; i < choices.Count; i++)
{
float angle = i * angleStep * Mathf.Deg2Rad;
Vector3 localOffset = new Vector3(Mathf.Cos(angle) * config.SpawnRadius, config.SpawnHeightOffset, Mathf.Sin(angle) * config.SpawnRadius);
originalPositions[i] = localOffset;
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
word.transform.localPosition = localOffset;
word.transform.LookAt(PlayerController.Instance.transform);
float scaleMultiplier = config.DefaultScaleMultiplier;
if (config.EnableScaleVariation)
{
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
}
word.transform.localScale = Vector3.one * scaleMultiplier;
word.Setup(choices[i], config);
words.Add(word);
}
return words;
}
public static void MergeWords(List<ChoiceFocusHandler> words, ObjectDescriptionSettings settings, System.Action onMergeComplete = null)
{
MergeWords(words, GetConfig(settings), onMergeComplete);
}
private static void MergeWords(List<ChoiceFocusHandler> words, SpawnConfig config, System.Action onMergeComplete)
{
for (int i = 0; i < words.Count; i++)
{
if (words[i] != null)
{
words[i].StopOrganicShake();
words[i].HideGhostTrail();
}
}
for (int i = 1; i < words.Count; i++)
{
if (words[i] != null)
{
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
if (canvasGroup != null)
{
TweenHelper.FadeCanvasGroup(canvasGroup, 0f, config.MergeDuration, null, LeanTweenType.easeInQuad);
}
}
}
if (words.Count > 0 && words[0] != null)
{
TweenHelper.ScaleLocal(
words[0].gameObject,
Vector3.one * config.MergeScalePulse,
config.MergeDuration / 2f,
() =>
{
if (words.Count > 0 && words[0] != null)
{
TweenHelper.ScaleLocal(
words[0].gameObject,
Vector3.one,
config.MergeDuration / 2f,
() => onMergeComplete.SafeInvoke(),
LeanTweenType.easeInBack
);
}
},
LeanTweenType.easeOutBack
);
}
}
public static void UnmergeWords(List<ChoiceFocusHandler> words, ObjectDescriptionSettings settings)
{
UnmergeWords(words, GetConfig(settings));
}
private static void UnmergeWords(List<ChoiceFocusHandler> words, SpawnConfig config)
{
for (int i = 0; i < words.Count; i++)
{
if (words[i] != null)
{
words[i].StartOrganicShake();
words[i].ShowGhostTrail();
}
}
for (int i = 1; i < words.Count; i++)
{
if (words[i] != null)
{
words[i].gameObject.CancelAllTweens();
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
if (canvasGroup != null)
{
TweenHelper.FadeCanvasGroup(canvasGroup, 1f, config.MergeDuration, null, LeanTweenType.easeOutQuad);
}
words[i].transform.localScale = Vector3.one;
}
}
if (words.Count > 0 && words[0] != null)
{
words[0].gameObject.CancelAllTweens();
words[0].transform.localScale = Vector3.one;
}
}
#region For obj description
public static GameObject CreateWordsContainer(string containerName, Transform anchor, ObjectDescriptionSettings settings)
{
SpawnConfig config = GetConfig(settings);
GameObject rotator = new GameObject($"{containerName}_Rotator");
rotator.transform.localPosition = Vector3.forward * config.ForwardOffset;
rotator.transform.localRotation = Quaternion.Euler(config.TiltAngle, 0f, 0f);
rotator.transform.localScale = Vector3.one * ROTATOR_SCALE;
Canvas containerCanvas = rotator.AddComponent<Canvas>();
containerCanvas.renderMode = RenderMode.WorldSpace;
return rotator;
}
public static void UpdateContainerRotation(GameObject container, GameObject rotator, Transform playerTransform, ObjectDescriptionSettings settings)
{
if (container == null || playerTransform == null) return;
Vector3 lookAtPosition = playerTransform.position;
lookAtPosition.y = container.transform.position.y;
container.transform.LookAt(lookAtPosition);
if (!settings.EnableRotation || rotator == null) return;
Vector3 rotationAxis = GetRotationAxisVector(settings.RotationAxis);
float rotationAmount = (360f / settings.RotationSpeed) * Time.deltaTime;
rotator.transform.Rotate(rotationAxis, rotationAmount, Space.Self);
}
#endregion
#region Depth Sorting
/// <summary>
/// Sorts words by distance to camera so closer words render on top via sibling index.
/// Ghost trails are synced to always sit just below their main word.
/// Shared by ObjectDescriptionTrigger and DialogueUI to avoid duplication.
/// Sorts a temporary copy to preserve the original list order, which may be index-coupled
/// to spawn positions (e.g. _originalSpawnPositions in ObjectDescriptionTrigger).
/// </summary>
public static void SortByDistance(List<ChoiceFocusHandler> words, Camera camera)
{
if (words.Count <= 1 || camera == null) return;
Vector3 cameraPosition = camera.transform.position;
List<ChoiceFocusHandler> sorted = new List<ChoiceFocusHandler>(words);
sorted.Sort((a, b) =>
{
if (a == null || b == null) return 0;
float dA = Vector3.Distance(cameraPosition, a.transform.position);
float dB = Vector3.Distance(cameraPosition, b.transform.position);
return dA.CompareTo(dB);
});
for (int i = 0; i < sorted.Count; i++)
{
if (sorted[i] != null)
{
sorted[i].transform.SetSiblingIndex(i);
sorted[i].SyncGhostSiblingIndex(i);
}
}
}
#endregion
#region Tools
private static Vector3 CalculateCircularPosition(float angleRad, float radius, RotationAxis axis, float heightOffset)
{
switch (axis)
{
case RotationAxis.Forward:
return new Vector3(Mathf.Cos(angleRad) * radius, Mathf.Sin(angleRad) * radius + heightOffset, 0f);
case RotationAxis.Up:
return new Vector3(Mathf.Cos(angleRad) * radius, heightOffset, Mathf.Sin(angleRad) * radius);
case RotationAxis.Right:
return new Vector3(0f, Mathf.Cos(angleRad) * radius + heightOffset, Mathf.Sin(angleRad) * radius);
default:
return new Vector3(Mathf.Cos(angleRad) * radius, heightOffset, Mathf.Sin(angleRad) * radius);
}
}
private static Vector3 GetRotationAxisVector(RotationAxis axis)
{
switch (axis)
{
case RotationAxis.Forward: return Vector3.forward;
case RotationAxis.Up: return Vector3.up;
case RotationAxis.Right: return Vector3.right;
default: return Vector3.up;
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b52b115a50463d46b645f5579cf22fd
@@ -0,0 +1,215 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Dialogue.UI
{
public class SketchFadeEffect : MonoBehaviour
{
private TextMeshProUGUI _textComponent;
private TextMeshProUGUI _ghostTextComponent;
private Image _scribbleOverlay;
private Material _scribbleMaterialInstance;
private Material _scribbleMaterialTemplate;
private static readonly int ProgressID = Shader.PropertyToID("_Progress");
private bool _isEffectActive = false;
private bool _overlayCreated = false;
private Color _originalTextColor;
private Color _originalGhostColor;
private int _scribbleTweenId = -1;
private int _fadeTweenId = -1;
public void Initialize(TextMeshProUGUI textComponent, TextMeshProUGUI ghostComponent, Material scribbleMaterial)
{
_textComponent = textComponent;
_ghostTextComponent = ghostComponent;
_scribbleMaterialTemplate = scribbleMaterial;
_originalTextColor = _textComponent.color;
if (_ghostTextComponent != null)
_originalGhostColor = _ghostTextComponent.color;
}
private void CreateScribbleOverlay()
{
if (_overlayCreated || _scribbleMaterialTemplate == null || _textComponent == null)
return;
GameObject overlayObject = new GameObject("ScribbleOverlay");
overlayObject.transform.SetParent(_textComponent.transform, false);
_scribbleOverlay = overlayObject.AddComponent<Image>();
_scribbleOverlay.raycastTarget = false;
RectTransform overlayRect = overlayObject.GetComponent<RectTransform>();
RectTransform textRect = _textComponent.GetComponent<RectTransform>();
_textComponent.ForceMeshUpdate();
Vector2 textSize = _textComponent.GetRenderedValues(false);
float horizontalPadding = 1.2f;
float verticalPadding = 0.8f;
Vector2 paddedSize = new Vector2(
textSize.x + horizontalPadding,
textSize.y + verticalPadding
);
overlayRect.anchorMin = new Vector2(0.5f, 0.5f);
overlayRect.anchorMax = new Vector2(0.5f, 0.5f);
overlayRect.pivot = new Vector2(0.5f, 0.5f);
float leftOffset = -horizontalPadding * 0.9f;
overlayRect.anchoredPosition = new Vector2(leftOffset, 0f);
overlayRect.sizeDelta = paddedSize;
overlayRect.localScale = Vector3.one;
_scribbleMaterialInstance = new Material(_scribbleMaterialTemplate);
_scribbleMaterialInstance.SetFloat(ProgressID, 0f);
_scribbleOverlay.material = _scribbleMaterialInstance;
_scribbleOverlay.gameObject.SetActive(false);
_overlayCreated = true;
}
public void PlaySketchFadeEffect(float scribbleDuration, float fadeDuration, float finalAlpha)
{
if (_isEffectActive || _textComponent == null)
return;
CancelActiveTweens();
CreateScribbleOverlay();
if (_scribbleOverlay == null || _scribbleMaterialInstance == null)
return;
_scribbleOverlay.gameObject.SetActive(true);
_isEffectActive = true;
_scribbleTweenId = LeanTween.value(gameObject, 0f, 1f, scribbleDuration)
.setEase(LeanTweenType.easeInOutQuad)
.setOnUpdate((float val) => {
if (_scribbleMaterialInstance != null)
_scribbleMaterialInstance.SetFloat(ProgressID, val);
})
.setOnComplete(() => {
_scribbleTweenId = -1;
float ghostAlphaRatio = _ghostTextComponent != null ? (_originalGhostColor.a / _originalTextColor.a) : 0f;
_fadeTweenId = LeanTween.value(gameObject, _originalTextColor.a, finalAlpha, fadeDuration)
.setEase(LeanTweenType.easeInQuad)
.setOnUpdate((float val) => {
if (_textComponent != null)
{
_textComponent.color = _textComponent.color.WithAlpha(val);
}
if (_ghostTextComponent != null)
{
_ghostTextComponent.color = _ghostTextComponent.color.WithAlpha(val * ghostAlphaRatio);
}
if (_scribbleOverlay != null)
{
_scribbleOverlay.color = _scribbleOverlay.color.WithAlpha(val);
}
})
.setOnComplete(() => {
_fadeTweenId = -1;
_isEffectActive = false;
}).id;
}).id;
}
public void ResetEffect()
{
if (!_overlayCreated || _scribbleOverlay == null)
{
_isEffectActive = false;
return;
}
bool overlayIsActive = _scribbleOverlay.gameObject.activeSelf;
if (!overlayIsActive)
{
_isEffectActive = false;
return;
}
CancelActiveTweens();
_isEffectActive = false;
if (_scribbleMaterialInstance != null)
{
float currentProgress = _scribbleMaterialInstance.GetFloat(ProgressID);
_scribbleTweenId = LeanTween.value(gameObject, currentProgress, 0f, 0.3f)
.setEase(LeanTweenType.easeOutQuad)
.setOnUpdate((float val) => {
if (_scribbleMaterialInstance != null)
_scribbleMaterialInstance.SetFloat(ProgressID, val);
})
.setOnComplete(() => {
_scribbleTweenId = -1;
if (_scribbleOverlay != null)
_scribbleOverlay.gameObject.SetActive(false);
}).id;
}
if (_textComponent != null)
{
float currentAlpha = _textComponent.color.a;
float ghostAlphaRatio = _ghostTextComponent != null ? (_originalGhostColor.a / _originalTextColor.a) : 0f;
_fadeTweenId = LeanTween.value(gameObject, currentAlpha, _originalTextColor.a, 0.3f)
.setEase(LeanTweenType.easeOutQuad)
.setOnUpdate((float val) => {
if (_textComponent != null)
{
_textComponent.color = _textComponent.color.WithAlpha(val);
}
if (_ghostTextComponent != null)
{
_ghostTextComponent.color = _ghostTextComponent.color.WithAlpha(val * ghostAlphaRatio);
}
if (_scribbleOverlay != null)
{
_scribbleOverlay.color = _scribbleOverlay.color.WithAlpha(val);
}
})
.setOnComplete(() => {
_fadeTweenId = -1;
}).id;
}
}
private void CancelActiveTweens()
{
TweenHelper.CancelTween(ref _scribbleTweenId);
TweenHelper.CancelTween(ref _fadeTweenId);
}
private void OnDestroy()
{
CancelActiveTweens();
if (_scribbleMaterialInstance != null)
Destroy(_scribbleMaterialInstance);
if (_scribbleOverlay != null)
Destroy(_scribbleOverlay.gameObject);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ee81e39cd832e34d9219099af39f4bb