Advanced POC - portals, dialogue system + floating words, room loading

This commit is contained in:
2026-01-26 20:31:27 +01:00
parent b5ca4763b3
commit 203b00578a
426 changed files with 123482 additions and 29 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 188b6b92f90c1d248a52afe96fff07b7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3392c3a642d4da4eb1d6311bc686747
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,12 @@
namespace Unheimlich.Interactions.Dialogues
{
/// <summary>
/// Serializable data structure representing a single dialogue choice option.
/// Links choice text to the next dialogue node via ID.
/// </summary>
[System.Serializable]
public class DialogueChoice
{
public string ChoiceText; // Text on the choice button
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e7a6b90d8597f824a9451b58900227b9
@@ -0,0 +1,84 @@
using UnityEngine;
using Unheimlich.Data.Dialogues;
using Unheimlich.Managers;
using Unheimlich.UI;
namespace Unheimlich.Interactions.Dialogues
{
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,185 @@
using UnityEngine;
using Unheimlich.Data.Dialogues;
using Unheimlich.Managers;
using Unheimlich.UI;
namespace Unheimlich.Interactions.Dialogues
{
/// <summary>
/// Trigger zone that starts a dialogue sequence when player enters.
/// Supports pause/resume on exit/enter and optional one-time triggering.
/// </summary>
[RequireComponent(typeof(Collider))]
public class DialogueTriggerZone : MonoBehaviour
{
[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;
[SerializeField] private bool _requirePlayerTag = true;
[SerializeField] private string _playerTag = "Player";
[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 Collider _triggerCollider;
private DialogueStateHandler _dialogueState;
private void Awake()
{
_triggerCollider = GetComponent<Collider>();
_dialogueState = new DialogueStateHandler(this);
if (!_triggerCollider.isTrigger)
{
Debug.LogWarning($"{gameObject.name}: Collider should be marked as Trigger!", this);
_triggerCollider.isTrigger = true;
}
if (_floatingWordAnchor == null)
_floatingWordAnchor = transform;
}
private void OnTriggerEnter(Collider other)
{
if (_requirePlayerTag && !other.CompareTag(_playerTag))
return;
if (_dialogueState.IsCompleted && !_isRetriggerable)
return;
if (_dialogueState.HasPausedDialogue)
{
_dialogueState.ResumeDialogue();
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
else if (!_hasTriggered || (_isRetriggerable && _dialogueState.IsCompleted))
{
TriggerDialogue();
}
}
private void OnTriggerExit(Collider other)
{
if (_requirePlayerTag && !other.CompareTag(_playerTag))
return;
if (_pauseOnExit && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
{
if ((Object)DialogueManager.Instance.CurrentOwner == this)
{
_dialogueState.PauseDialogue();
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
}
}
}
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;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
{
DialogueBlurEffectURP.Instance.ShowBlur();
}
DialogueManager.Instance.StartConversation(
_conversation,
_floatingWordAnchor,
_animationSettings,
_localDialogueUI,
this
);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
}
private void OnDialogueCompleted(object owner)
{
if ((Object)owner == this)
{
_dialogueState.MarkAsCompleted();
if (_isRetriggerable)
{
_hasTriggered = false;
_dialogueState.AllowRetrigger();
}
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
}
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
public void ResetTrigger()
{
_hasTriggered = false;
_dialogueState.Reset();
}
private void OnDestroy()
{
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
private void OnDrawGizmosSelected()
{
Collider col = GetComponent<Collider>();
if (col != null)
{
Gizmos.color = _hasTriggered ? Color.gray : Color.yellow;
Gizmos.matrix = transform.localToWorldMatrix;
if (col is BoxCollider box)
Gizmos.DrawWireCube(box.center, box.size);
else if (col is SphereCollider sphere)
Gizmos.DrawWireSphere(sphere.center, sphere.radius);
}
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.Interactions.Core;
using Unheimlich.Data.Dialogues;
using Unheimlich.Managers;
using Unheimlich.Gameplay.Player;
using Unheimlich.UI;
namespace Unheimlich.Interactions.Dialogues
{
/// <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 && Player.Instance != null)
{
float distance = Vector3.Distance(transform.position, Player.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(GameObject interactor)
{
if (DialogueManager.Instance == null)
return;
if (_dialogueState.IsCompleted && !_isRetriggerable)
return;
if (_dialogueState.HasPausedDialogue)
{
_dialogueState.ResumeDialogue();
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
else
{
if (Conversation == null)
{
Debug.LogWarning($"NPC {gameObject.name} has no conversation configured!");
return;
}
DialogueManager.Instance.StartConversation(Conversation, _floatingWordAnchor, _animationSettings, _localDialogueUI, this);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
}
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 (Player.Instance == null) return;
Vector3 lookAtPosition = Player.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: c2c6fc4373abfa54fa4ce7c0392875f1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,501 @@
using UnityEngine;
using TMPro;
using System.Collections.Generic;
using Unheimlich.Data.Dialogues;
using Unheimlich.Managers;
using Unheimlich.Gameplay.Player;
namespace Unheimlich.Interactions.FloatingWords
{
/// <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?.ChoiceText ?? 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 = 150f;
[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 bool _positionCaptured = false;
private Vector3 _shakeOffset = Vector3.zero;
private Vector3 _shakeTarget = Vector3.zero;
private float _shakeTimer = 0f;
private bool _isShaking = false;
private bool _disableFloatingEffects = false;
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
private float _historyTimer = 0f;
private const float HISTORY_SAMPLE_RATE = 0.016f;
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;
Canvas ghostCanvas = ghostObj.AddComponent<Canvas>();
ghostCanvas.overrideSorting = true;
ghostCanvas.sortingOrder = -1;
_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();
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;
}
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)
{
_startLocalPos = transform.localPosition;
_positionCaptured = true;
LeanTween.cancel(gameObject);
StopOrganicShake();
}
transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress);
bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus();
if (shouldMoveToPlayer && Player.Instance != null)
{
float distance = GetFocusDistanceFromPlayer();
Vector3 targetPos = Player.Instance.transform.position
+ Player.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;
LeanTween.cancel(gameObject);
SetupGhostTrail();
if (_ghostTextComponent != null)
_ghostTextComponent.text = GetChoiceText();
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);
StopOrganicShake();
LeanTween.moveLocal(gameObject, _startLocalPos, duration)
.setEase(LeanTweenType.easeOutQuad)
.setOnComplete(() => {
_positionCaptured = false;
if (DialogueManager.Instance != null)
DialogueManager.Instance.UnlockFocus();
StartOrganicShake();
});
transform.localScale = Vector3.one;
if (_choiceTextComponent != null)
{
_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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: abd0f63a44b2272448eb41767c2ce44e
@@ -0,0 +1,367 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Data.Dialogues;
using Unheimlich.Gameplay.Player;
namespace Unheimlich.Interactions.FloatingWords
{
/// <summary>
/// Utility class for spawning floating 3D text choices in world space.
/// Used for both dialogue choices and object descriptions. Handles positioning,
/// rotation, animations (spawn, merge, floating) using LeanTween for smooth effects.
/// </summary>
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;
}
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
};
}
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
};
}
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;
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;
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 (Player.Instance == null) return words;
Vector3 playerForward = Player.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 = Player.Instance.transform.position + offset;
word.transform.LookAt(Player.Instance.transform);
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 (Player.Instance == null) return words;
parent.position = Player.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(Player.Instance.transform);
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)
{
LeanTween.value(words[i].gameObject, 1f, 0f, config.MergeDuration)
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
.setEaseInQuad();
}
}
}
if (words.Count > 0 && words[0] != null)
{
LeanTween.scale(words[0].gameObject, Vector3.one * config.MergeScalePulse, config.MergeDuration / 2f)
.setEaseOutBack()
.setOnComplete(() =>
{
if (words.Count > 0 && words[0] != null)
{
LeanTween.scale(words[0].gameObject, Vector3.one, config.MergeDuration / 2f)
.setEaseInBack()
.setOnComplete(() => onMergeComplete?.Invoke());
}
});
}
}
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)
{
LeanTween.cancel(words[i].gameObject);
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
if (canvasGroup != null)
{
LeanTween.value(words[i].gameObject, canvasGroup.alpha, 1f, config.MergeDuration)
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
.setEaseOutQuad();
}
words[i].transform.localScale = Vector3.one;
}
}
if (words.Count > 0 && words[0] != null)
{
LeanTween.cancel(words[0].gameObject);
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 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,87 @@
using UnityEngine;
using Unheimlich.Managers;
namespace Unheimlich.Interactions.Core
{
/// <summary>
/// Detects interactable objects in front of the player camera using raycasts.
/// Triggers interaction when player presses interact button while looking at an interactable.
/// </summary>
public class InteractionHandler : MonoBehaviour
{
public static InteractionHandler Instance { get; private set; }
[Header("Settings")]
[SerializeField] private float _interactionRange = 1f;
[SerializeField] private LayerMask _interactableLayers;
[SerializeField] private float _raycastInterval = 0.1f;
private InteractableBase _currentInteractable;
private Camera _playerCamera;
private float _raycastTimer;
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(this);
return;
}
Instance = this;
_playerCamera = GetComponent<Camera>();
}
private void Update()
{
_raycastTimer += Time.deltaTime;
if (_raycastTimer >= _raycastInterval)
{
CheckForInteractable();
_raycastTimer = 0f;
}
HandleInteractionInput();
}
/// <summary>
/// Performs raycast from camera center to detect interactable objects within range.
/// Updates the current interactable reference for input handling.
/// </summary>
private void CheckForInteractable()
{
Ray ray = _playerCamera.ViewportPointToRay(ViewportCenter);
if (Physics.Raycast(ray, out RaycastHit hit, _interactionRange, _interactableLayers))
{
InteractableBase hitInteractable = hit.collider.GetComponent<InteractableBase>();
_currentInteractable = hitInteractable;
}
else
_currentInteractable = null;
}
private void HandleInteractionInput()
{
if (PlayerInputManager.Instance != null &&
PlayerInputManager.Instance.InteractAction.action.WasPressedThisFrame())
{
if (_currentInteractable != null)
_currentInteractable.Interact(transform.parent.gameObject);
}
}
private void OnDrawGizmosSelected()
{
if (_playerCamera != null)
{
Gizmos.color = _currentInteractable != null ? Color.green : Color.red;
Ray ray = _playerCamera.ViewportPointToRay(ViewportCenter);
Gizmos.DrawRay(ray.origin, ray.direction * _interactionRange);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d96ceb0ee513b1e4197e55e9b926269b
@@ -0,0 +1,17 @@
using UnityEngine;
namespace Unheimlich.Interactions.Core
{
/// <summary>
/// Base class for all interactable objects in the game.
/// Derived classes must implement Interact() to define interaction behavior.
/// </summary>
public abstract class InteractableBase : MonoBehaviour
{
[Header("Interaction Settings")]
[Tooltip("Text displayed when the player looks at the object")]
public string InteractionText = "Interact";
public abstract void Interact(GameObject gameObject);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c2bd6cb9667f8674294172efbdf8afd3
@@ -0,0 +1,304 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Data.Dialogues;
using Unheimlich.Gameplay.Player;
namespace Unheimlich.Interactions.FloatingWords
{
public class ObjectDescriptionTrigger : MonoBehaviour
{
[Header("Description")]
[SerializeField] private string _descriptionText = "Mysterious Object";
[Header("Settings")]
[SerializeField] private ObjectDescriptionSettings _settings;
[Header("Anchor")]
[SerializeField] private Transform _descriptionAnchor;
[Header("Prefab")]
[SerializeField] private ChoiceFocusHandler _wordPrefab;
private Transform _playerTransform;
private Camera _mainCamera;
private List<ChoiceFocusHandler> _spawnedWords = new List<ChoiceFocusHandler>();
private GameObject _wordsContainer;
private GameObject _wordsRotator;
private bool _isActive = false;
private bool _isMerged = false;
private bool _hasBeenMergedOnce = false;
private bool _hasCompletedMergeOnce = false;
private Vector3[] _originalSpawnPositions;
private Vector3[] _basePositions;
private float _currentConvergenceRadius;
public bool IsAnimationActive => _isActive && (!_settings.MergeOnce || !_hasCompletedMergeOnce);
public bool IsInMergeOnceMode => _settings != null && _settings.MergeOnce;
void Start()
{
_playerTransform = Player.Instance.transform;
_mainCamera = Camera.main;
if (_playerTransform == null)
Debug.LogWarning($"[ObjectDescription] No player found for {gameObject.name}");
if (_descriptionAnchor == null)
_descriptionAnchor = transform;
if (_settings == null)
{
Debug.LogError($"[ObjectDescription] No settings assigned for {gameObject.name}");
enabled = false;
}
}
void Update()
{
if (_playerTransform == null || _settings == null) return;
float distanceToPlayer = Vector3.Distance(_playerTransform.position, transform.position);
if (!_isActive && distanceToPlayer <= _settings.ActivationDistance)
{
if (!_settings.MergeOnce || !_hasCompletedMergeOnce)
SpawnWords();
}
else if (_isActive && distanceToPlayer >= _settings.DeactivationDistance)
DespawnWords();
if (_isActive)
{
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
{
UpdateWordConvergence();
UpdateContainerRotation();
}
}
}
private void SpawnWords()
{
if (_wordPrefab == null)
{
Debug.LogError($"[ObjectDescription] No word prefab assigned for {gameObject.name}");
return;
}
_isActive = true;
_isMerged = false;
_currentConvergenceRadius = _settings.SpawnRadius;
_wordsContainer = new GameObject($"{_descriptionText}_Container");
_wordsContainer.transform.position = _descriptionAnchor.position;
_wordsContainer.transform.rotation = _descriptionAnchor.rotation;
_wordsRotator = FloatingWordsSpawner.CreateWordsContainer(_descriptionText, _descriptionAnchor, _settings);
_wordsRotator.transform.SetParent(_wordsContainer.transform, false);
ObjectDescriptionTriggerReference triggerRef = _wordsRotator.AddComponent<ObjectDescriptionTriggerReference>();
triggerRef.Trigger = this;
List<DialogueChoiceData> dummyChoices = new List<DialogueChoiceData>();
for (int i = 0; i < _settings.WordCount; i++)
{
dummyChoices.Add(new DialogueChoiceData
{
ChoiceText = _descriptionText,
NextNode = null
});
}
_spawnedWords = FloatingWordsSpawner.SpawnByMode(
dummyChoices,
_wordPrefab,
_wordsRotator.transform,
_settings,
out _originalSpawnPositions
);
_basePositions = new Vector3[_spawnedWords.Count];
for (int i = 0; i < _spawnedWords.Count; i++)
_basePositions[i] = _spawnedWords[i].transform.localPosition;
}
private void DespawnWords()
{
_isActive = false;
_isMerged = false;
foreach (var word in _spawnedWords)
{
if (word != null)
Destroy(word.gameObject);
}
_spawnedWords.Clear();
if (_wordsContainer != null)
Destroy(_wordsContainer);
// Debug.Log($"[ObjectDescription] Despawned words for '{descriptionText}'");
}
private void UpdateWordConvergence()
{
if (_mainCamera == null || _spawnedWords.Count == 0) return;
Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f);
Ray ray = _mainCamera.ScreenPointToRay(screenCenter);
RaycastHit hit;
int layerMask = ~LayerMask.GetMask("Words", "UI");
bool isHoveringObject = false;
if (Physics.Raycast(ray, out hit, 100f, layerMask))
{
if (hit.collider.gameObject == gameObject || hit.collider.transform.IsChildOf(transform))
isHoveringObject = true;
}
float targetRadius = isHoveringObject ? _settings.MinConvergenceRadius : _settings.SpawnRadius;
_currentConvergenceRadius = Mathf.Lerp(_currentConvergenceRadius, targetRadius, Time.deltaTime * _settings.ConvergenceSpeed);
if (isHoveringObject && !_isMerged && _currentConvergenceRadius <= _settings.MinConvergenceRadius * 5f)
MergeWords();
else if (!isHoveringObject && _isMerged)
{
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
UnmergeWords();
}
if (_isMerged) return;
float convergenceFactor = Mathf.InverseLerp(0f, _settings.SpawnRadius, _currentConvergenceRadius);
for (int i = 0; i < _spawnedWords.Count; i++)
{
if (_spawnedWords[i] == null) continue;
Vector3 targetLocalPos = GetConvergenceTargetPosition(_originalSpawnPositions[i], _currentConvergenceRadius);
_basePositions[i] = Vector3.Lerp(
_basePositions[i],
targetLocalPos,
Time.deltaTime * _settings.ConvergenceSpeed
);
_spawnedWords[i].transform.localPosition = _basePositions[i] + _spawnedWords[i].GetShakeOffset(convergenceFactor);
}
}
private Vector3 GetConvergenceTargetPosition(Vector3 originalPosition, float radius)
{
if (radius < 0.01f) return Vector3.zero;
switch (_settings.RotationAxis)
{
case RotationAxis.Forward:
Vector2 xyNormalized = new Vector2(originalPosition.x, originalPosition.y - _settings.SpawnHeightOffset).normalized;
return new Vector3(
xyNormalized.x * radius,
xyNormalized.y * radius + _settings.SpawnHeightOffset,
originalPosition.z
);
case RotationAxis.Up:
Vector2 xzNormalized = new Vector2(originalPosition.x, originalPosition.z).normalized;
return new Vector3(
xzNormalized.x * radius,
originalPosition.y,
xzNormalized.y * radius
);
case RotationAxis.Right:
Vector2 yzNormalized = new Vector2(originalPosition.y - _settings.SpawnHeightOffset, originalPosition.z).normalized;
return new Vector3(
originalPosition.x,
yzNormalized.x * radius + _settings.SpawnHeightOffset,
yzNormalized.y * radius
);
default:
return originalPosition.normalized * radius;
}
}
private void MergeWords()
{
if (_isMerged) return;
_isMerged = true;
_hasBeenMergedOnce = true;
FloatingWordsSpawner.MergeWords(_spawnedWords, _settings, () =>
{
if (_settings.MergeOnce)
{
LeanTween.delayedCall(_settings.MergeOnceDisplayDuration, () =>
{
FadeOutAndComplete();
});
}
});
}
private void FadeOutAndComplete()
{
if (_spawnedWords.Count > 0 && _spawnedWords[0] != null)
{
CanvasGroup canvasGroup = _spawnedWords[0].GetComponent<CanvasGroup>();
if (canvasGroup != null)
{
LeanTween.value(_spawnedWords[0].gameObject, 1f, 0f, _settings.MergeDuration)
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
.setEaseInQuad()
.setOnComplete(() =>
{
_hasCompletedMergeOnce = true;
DespawnWords();
});
}
}
}
private void UnmergeWords()
{
if (!_isMerged) return;
_isMerged = false;
FloatingWordsSpawner.UnmergeWords(_spawnedWords, _settings);
}
private void UpdateContainerRotation()
{
FloatingWordsSpawner.UpdateContainerRotation(_wordsContainer, _wordsRotator, _playerTransform, _settings);
}
public ObjectDescriptionSettings GetSettings()
{
return _settings;
}
void OnDrawGizmosSelected()
{
if (_settings == null) return;
Transform anchor = _descriptionAnchor != null ? _descriptionAnchor : transform;
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, _settings.ActivationDistance);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, _settings.DeactivationDistance);
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(anchor.position, _settings.SpawnRadius);
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(anchor.position, _settings.MinConvergenceRadius);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1fe462730ebdae488f89f5868a4c74c
@@ -0,0 +1,9 @@
using UnityEngine;
namespace Unheimlich.Interactions.FloatingWords
{
public class ObjectDescriptionTriggerReference : MonoBehaviour
{
public ObjectDescriptionTrigger Trigger;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ddb0acc42d86604c9dbf91872a216d9
@@ -0,0 +1,142 @@
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
using Unheimlich.Gameplay.Items;
using InputManager = Unheimlich.Managers.PlayerInputManager;
namespace Unheimlich.Interactions.Core
{
public class PlayerInspectionHandler : MonoBehaviour
{
public static PlayerInspectionHandler Instance { get; private set; }
[Header("Components")]
[SerializeField] private Transform _inspectionHoldPoint;
[Header("State")]
public bool IsInspecting { get; private set; } = false;
private InspectableItem _currentInspectedItem;
private Coroutine _interactionListener;
private bool _isUsingGamepad = false;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(this);
return;
}
Instance = this;
}
/// <summary>
/// Toggles inspection mode for an item. When inspecting, disables player movement
/// and allows item rotation using look input until interaction key is pressed again.
/// </summary>
public void ToggleInspection(InspectableItem item)
{
if (!IsInspecting)
{
StartInspection(item);
}
else
{
if (_interactionListener != null)
StopInspection();
}
}
private void StartInspection(InspectableItem item)
{
IsInspecting = true;
_currentInspectedItem = item;
InputManager.ToggleInput(false);
item.PrepareForInspection(_inspectionHoldPoint);
if (_interactionListener == null)
_interactionListener = StartCoroutine(ListenForInteraction());
}
private void StopInspection()
{
if (_interactionListener != null)
{
StopCoroutine(_interactionListener);
_interactionListener = null;
}
StopInspectionInternal();
}
private void StopInspectionInternal()
{
if (_currentInspectedItem != null)
{
_currentInspectedItem.Release();
_currentInspectedItem = null;
StartCoroutine(DelayedToggleInput(true));
}
IsInspecting = false;
}
private IEnumerator DelayedToggleInput(bool enable)
{
yield return new WaitForEndOfFrame();
InputManager.ToggleInput(enable);
}
private IEnumerator ListenForInteraction()
{
yield return null;
while (IsInspecting)
{
if (InputManager.Instance.InteractAction.action.WasPressedThisFrame())
{
StopInspectionInternal();
yield break;
}
yield return null;
}
_interactionListener = null;
}
private void Update()
{
if (IsInspecting && _currentInspectedItem != null)
{
HandleObjectRotation();
}
}
private void HandleObjectRotation()
{
if (InputManager.Instance == null) return;
Vector2 lookInput = InputManager.Instance.LookInput;
if (lookInput != Vector2.zero)
{
if (Gamepad.current != null && Gamepad.current.rightStick.ReadValue().magnitude > 0.01f)
_isUsingGamepad = true;
else if (Mouse.current != null && Mouse.current.delta.ReadValue().magnitude > 0.01f)
_isUsingGamepad = false;
float deltaMultiplier = _isUsingGamepad ? Time.deltaTime : 0.005f;
float rotX = lookInput.y * _currentInspectedItem.RotationSpeed * deltaMultiplier;
float rotY = lookInput.x * _currentInspectedItem.RotationSpeed * deltaMultiplier;
_currentInspectedItem.transform.Rotate(transform.up, -rotY, Space.World);
_currentInspectedItem.transform.Rotate(transform.right, rotX, Space.World);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 464960797e705e84a9eb87ecce4e9311