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; /// /// Internal configuration struct to unify settings from different sources. /// 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 SpawnByMode(List nodeChoices, ChoiceFocusHandler prefab, Transform container, DialogueAnimationSettings settings, out Vector3[] originalPositions) { return SpawnByMode(nodeChoices, prefab, container, GetConfig(settings), out originalPositions); } public static List SpawnByMode(List choices, ChoiceFocusHandler prefab, Transform container, ObjectDescriptionSettings settings, out Vector3[] originalPositions) { return SpawnByMode(choices, prefab, container, GetConfig(settings), out originalPositions); } private static List SpawnByMode(List 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 SpawnWordsInCircle(List choices, ChoiceFocusHandler wordPrefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions) { List words = new List(); 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(); if (canvasGroup == null) canvasGroup = word.gameObject.AddComponent(); canvasGroup.alpha = 1f; word.Setup(choices[i], config, config.DisableFloatingEffects); if (config.EnableOrganicShake) word.StartOrganicShake(); words.Add(word); } return words; } private static List SpawnAtAnchor(List choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config) { List words = new List(); 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 SpawnInFrontOfPlayer(List choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config) { List words = new List(); 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 SpawnAroundPlayer(List choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions) { List words = new List(); 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 words, ObjectDescriptionSettings settings, System.Action onMergeComplete = null) { MergeWords(words, GetConfig(settings), onMergeComplete); } private static void MergeWords(List 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(); 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 words, ObjectDescriptionSettings settings) { UnmergeWords(words, GetConfig(settings)); } private static void UnmergeWords(List 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(); 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(); 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 /// /// 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). /// public static void SortByDistance(List words, Camera camera) { if (words.Count <= 1 || camera == null) return; Vector3 cameraPosition = camera.transform.position; List sorted = new List(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 } }