Files
Ipseity-Demo/Assets/Unheimlich_POC/Scripts/Features/Hallucination/Runtime/HallucinationWordSpawner.cs
T
2026-03-16 17:06:47 +01:00

249 lines
10 KiB
C#

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Features.Player;
using Unheimlich.Core.Utils;
using RotationAxis = Unheimlich.Features.Dialogue.Data.RotationAxis;
using ChoiceSpawnMode = Unheimlich.Features.Dialogue.Data.ChoiceSpawnMode;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Listens to HallucinationManager.OnHallucinationTriggered and spawns ghost words
/// that float in world space and drift toward the player.
/// Reuses the existing ChoiceFocusHandler prefab and its organic shake system.
/// Add this component to the HallucinationManager GameObject or any persistent scene object.
/// </summary>
public class HallucinationWordSpawner : MonoBehaviour
{
private const float CanvasLocalScale = 0.01f;
[Header("References")]
[Tooltip("Prefab used to render each ghost word (ChoiceFocusHandler)")]
[SerializeField] private ChoiceFocusHandler _wordPrefab;
[Header("Ghost Word Behaviour")]
[Tooltip("Speed at which the ghost word drifts toward the player")]
[SerializeField] private float _driftSpeed = 0.4f;
[Tooltip("Distance from the player at which the ghost word stops drifting")]
[SerializeField] private float _stopDistance = 1.5f;
[Tooltip("Fade-in duration when a ghost word appears")]
[SerializeField] private float _fadeInDuration = 0.5f;
[Tooltip("Fade-out duration before the ghost word is destroyed")]
[SerializeField] private float _fadeOutDuration = 0.8f;
[Header("Shake Settings")]
[Tooltip("Shake radius applied to ghost words (world units)")]
[SerializeField] private float _shakeRadius = 15f;
[Tooltip("Shake speed")]
[SerializeField] private float _shakeSpeed = 2f;
[Tooltip("Shake smoothness")]
[SerializeField] private float _shakeSmoothness = 5f;
private readonly List<GhostWordInstance> _activeWords = new List<GhostWordInstance>();
// ─── Lifecycle ─────────────────────────────────────────────────────────────
private void OnEnable()
{
if (HallucinationManager.Instance != null)
HallucinationManager.Instance.OnHallucinationTriggered += OnHallucinationTriggered;
}
private void OnDisable()
{
if (HallucinationManager.Instance != null)
HallucinationManager.Instance.OnHallucinationTriggered -= OnHallucinationTriggered;
}
private void Update()
{
for (int i = _activeWords.Count - 1; i >= 0; i--)
{
GhostWordInstance instance = _activeWords[i];
if (instance.Handler == null)
{
_activeWords.RemoveAt(i);
continue;
}
DriftTowardPlayer(instance);
}
}
// ─── Event Handler ─────────────────────────────────────────────────────────
private void OnHallucinationTriggered(HallucinationEvent hallucinationEvent)
{
if (!hallucinationEvent.SpawnGhostWord || _wordPrefab == null)
return;
SpawnGhostWord(hallucinationEvent);
}
// ─── Spawn ─────────────────────────────────────────────────────────────────
private void SpawnGhostWord(HallucinationEvent hallucinationEvent)
{
if (PlayerController.Instance == null)
{
Debug.LogWarning("[HallucinationWordSpawner] PlayerController not found, cannot spawn ghost word.", this);
return;
}
// World-space canvas container — mirrors the pattern in FloatingWordsSpawner
GameObject container = new GameObject($"GhostWord_{hallucinationEvent.Label}");
Canvas canvas = container.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
container.transform.localScale = Vector3.one * CanvasLocalScale;
// Position relative to player + event offset
Vector3 spawnPosition = PlayerController.Instance.transform.position
+ PlayerController.Instance.transform.TransformDirection(hallucinationEvent.GhostWordSpawnOffset);
container.transform.position = spawnPosition;
container.transform.LookAt(PlayerController.Instance.transform.position);
// Spawn the word handler
ChoiceFocusHandler handler = Instantiate(_wordPrefab, container.transform);
handler.transform.localPosition = Vector3.zero;
handler.transform.localRotation = Quaternion.identity;
handler.transform.localScale = Vector3.one;
// Build a ghost-only DialogueChoiceData (no NextNode, no localization key)
DialogueChoiceData ghostData = new DialogueChoiceData
{
ChoiceTextOverride = hallucinationEvent.GhostWordText,
NextNode = null
};
FloatingWordsSpawner.SpawnConfig config = BuildGhostConfig();
handler.Setup(ghostData, config, disableFloatingEffects: false);
handler.StartOrganicShake();
// Fade-in via CanvasGroup
CanvasGroup canvasGroup = handler.GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = handler.gameObject.AddComponent<CanvasGroup>();
canvasGroup.alpha = 0f;
TweenHelper.FadeCanvasGroup(canvasGroup, 1f, _fadeInDuration, null, LeanTweenType.easeOutQuad);
GhostWordInstance instance = new GhostWordInstance
{
Handler = handler,
Container = container,
CanvasGroup = canvasGroup,
Lifetime = hallucinationEvent.GhostWordLifetime,
ElapsedTime = 0f,
IsFadingOut = false
};
_activeWords.Add(instance);
StartCoroutine(LifetimeRoutine(instance));
}
// ─── Drift ─────────────────────────────────────────────────────────────────
private void DriftTowardPlayer(GhostWordInstance instance)
{
if (PlayerController.Instance == null || instance.IsFadingOut)
return;
Transform container = instance.Container.transform;
Vector3 playerPos = PlayerController.Instance.transform.position;
float distance = Vector3.Distance(container.position, playerPos);
if (distance > _stopDistance)
{
Vector3 direction = (playerPos - container.position).normalized;
container.position += direction * _driftSpeed * Time.deltaTime;
}
// Always face the player
container.LookAt(playerPos);
}
// ─── Lifetime ──────────────────────────────────────────────────────────────
private IEnumerator LifetimeRoutine(GhostWordInstance instance)
{
float aliveTime = Mathf.Max(0f, instance.Lifetime - _fadeOutDuration);
yield return new WaitForSeconds(aliveTime);
if (instance.Handler == null)
yield break;
instance.IsFadingOut = true;
instance.Handler.StopOrganicShake();
if (instance.CanvasGroup != null)
TweenHelper.FadeCanvasGroup(instance.CanvasGroup, 0f, _fadeOutDuration, null, LeanTweenType.easeInQuad);
yield return new WaitForSeconds(_fadeOutDuration);
_activeWords.Remove(instance);
if (instance.Container != null)
Destroy(instance.Container);
}
// ─── Config ────────────────────────────────────────────────────────────────
private FloatingWordsSpawner.SpawnConfig BuildGhostConfig()
{
float sanityMultiplier = HallucinationManager.Instance != null
? HallucinationManager.Instance.WordShakeMultiplier
: 1f;
return new FloatingWordsSpawner.SpawnConfig
{
SpawnRadius = 0f,
SpawnHeightOffset = 0f,
RotationAxis = RotationAxis.Up,
EnableOrganicShake = true,
ForwardOffset = 0f,
TiltAngle = 0f,
RotationSpeed = 0f,
EnableRotation = false,
SpawnMode = ChoiceSpawnMode.AtAnchorPosition,
EnableFloating = false,
MergeDuration = 0.3f,
MergeScalePulse = 1f,
EnableGhostTrail = true,
GhostTrailAlpha = 0.25f,
GhostTrailDelay = 0.08f,
ShakeRadius = _shakeRadius * sanityMultiplier,
ShakeSpeed = _shakeSpeed,
ShakeSmoothness = _shakeSmoothness,
MoveTowardPlayerOnFocus = false,
FocusDistanceFromPlayer = _stopDistance,
DisableFloatingEffects = false,
DefaultScaleMultiplier = 1f,
EnableScaleVariation = false,
MinScaleVariation = 1f,
MaxScaleVariation = 1f
};
}
// ─── Internal ──────────────────────────────────────────────────────────────
private class GhostWordInstance
{
public ChoiceFocusHandler Handler;
public GameObject Container;
public CanvasGroup CanvasGroup;
public float Lifetime;
public float ElapsedTime;
public bool IsFadingOut;
}
}
}