Hallucination system

This commit is contained in:
Ferlat Thibaud
2026-03-16 17:06:47 +01:00
parent 10faf74405
commit 0d968f75ab
44 changed files with 2860 additions and 909 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f73b9ba084838014a956e0a0a4150ab7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,42 @@
using UnityEngine;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// ScriptableObject describing a discrete hallucination event.
/// Configure in the inspector and pass to HallucinationManager.TriggerHallucinationEvent().
/// </summary>
[CreateAssetMenu(fileName = "HallucinationEvent", menuName = "Unheimlich/Hallucination/Hallucination Event")]
public class HallucinationEvent : ScriptableObject
{
[Header("Identity")]
[Tooltip("Human-readable label for this event (used in logs and the inspector)")]
[SerializeField] private string _label;
[Header("Ghost Word")]
[Tooltip("Spawn a ghost word that moves toward the player")]
[SerializeField] private bool _spawnGhostWord;
[Tooltip("Text displayed by the ghost word")]
[SerializeField] private string _ghostWordText;
[Tooltip("World-space spawn position offset from the anchor point")]
[SerializeField] private Vector3 _ghostWordSpawnOffset = new Vector3(0f, 1.5f, 2f);
[Tooltip("Duration before the ghost word disappears (seconds)")]
[SerializeField] private float _ghostWordLifetime = 3f;
[Header("Sanity Cost")]
[Tooltip("Sanity added to the player when this event fires (0 = no change)")]
[SerializeField, Range(0f, 1f)] private float _sanityCost = 0f;
// ─── Public Accessors ──────────────────────────────────────────────────────
public string Label => _label;
public bool SpawnGhostWord => _spawnGhostWord;
public string GhostWordText => _ghostWordText;
public Vector3 GhostWordSpawnOffset => _ghostWordSpawnOffset;
public float GhostWordLifetime => _ghostWordLifetime;
public float SanityCost => _sanityCost;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4c627a455825134087ba8c6f8931aed
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9bb36c8cb81b7fd49b9612c4fa8f8c80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,272 @@
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using System;
using System.Collections;
using Unheimlich.Core;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Central manager for the hallucination system.
/// Tracks the player's sanity level (0 = sane, 1 = fully insane) and drives all
/// hallucination effects: individual URP post-process overrides (with lerp), word shake
/// intensity, and ghost-word visibility via the HallucinationWordSpawner.
///
/// Effects are applied directly on VolumeProfile overrides rather than on volume.weight,
/// so multiple volumes coexist without parameter conflicts.
/// </summary>
public class HallucinationManager : Singleton<HallucinationManager>
{
// ─── Constants ─────────────────────────────────────────────────────────────
private const float MinSanity = 0f;
private const float MaxSanity = 1f;
// ─── Inspector ─────────────────────────────────────────────────────────────
[Header("Sanity")]
[Tooltip("Starting sanity level (0 = sane, 1 = fully insane)")]
[SerializeField, Range(0f, 1f)] private float _initialSanity = 0f;
[Tooltip("Sanity naturally recovers over time at this rate per second (0 = no recovery)")]
[SerializeField, Range(0f, 0.1f)] private float _sanityRecoveryRate = 0f;
[Header("Effect Lerp")]
[Tooltip("Speed at which visual effects lerp toward the target sanity value")]
[SerializeField] private float _effectLerpSpeed = 2f;
[Header("URP Post-Process Volume")]
[Tooltip("Volume containing the hallucination overrides (Chromatic Aberration, Lens Distortion, etc.)")]
[SerializeField] private Volume _hallucinationVolume;
[Header("Chromatic Aberration")]
[SerializeField] private bool _driveChromaticAberration = true;
[SerializeField, Range(0f, 1f)] private float _chromaticAberrationMin = 0f;
[SerializeField, Range(0f, 1f)] private float _chromaticAberrationMax = 0.8f;
[Header("Lens Distortion")]
[SerializeField] private bool _driveLensDistortion = true;
[SerializeField, Range(-1f, 1f)] private float _lensDistortionMin = 0f;
[SerializeField, Range(-1f, 1f)] private float _lensDistortionMax = -0.4f;
[Header("Vignette")]
[SerializeField] private bool _driveVignette = true;
[SerializeField, Range(0f, 1f)] private float _vignetteMin = 0.3f;
[SerializeField, Range(0f, 1f)] private float _vignetteMax = 0.7f;
[Header("Film Grain")]
[SerializeField] private bool _driveFilmGrain = true;
[SerializeField, Range(0f, 1f)] private float _filmGrainMin = 0f;
[SerializeField, Range(0f, 1f)] private float _filmGrainMax = 0.6f;
[Header("Bloom")]
[SerializeField] private bool _driveBloom = true;
[Tooltip("Bloom intensity at sanity = 0")]
[SerializeField, Min(0f)] private float _bloomIntensityMin = 0f;
[Tooltip("Bloom intensity at sanity = 1")]
[SerializeField, Min(0f)] private float _bloomIntensityMax = 1f;
[Tooltip("Bloom threshold at sanity = 0 — raise this to suppress any bleed at zero sanity")]
[SerializeField, Min(0f)] private float _bloomThresholdMin = 1f;
[Tooltip("Bloom threshold at sanity = 1")]
[SerializeField, Min(0f)] private float _bloomThresholdMax = 0.5f;
[Tooltip("Bloom scatter (spread) at sanity = 0")]
[SerializeField, Range(0f, 1f)] private float _bloomScatterMin = 0f;
[Tooltip("Bloom scatter (spread) at sanity = 1")]
[SerializeField, Range(0f, 1f)] private float _bloomScatterMax = 0.7f;
[Tooltip("Bloom dirt texture intensity at sanity = 0")]
[SerializeField, Min(0f)] private float _bloomDirtIntensityMin = 0f;
[Tooltip("Bloom dirt texture intensity at sanity = 1")]
[SerializeField, Min(0f)] private float _bloomDirtIntensityMax = 0f;
[Header("Word Shake")]
[Tooltip("Baseline shake multiplier at sanity = 0")]
[SerializeField] private float _shakeMultiplierAtZero = 1f;
[Tooltip("Max shake multiplier at sanity = 1")]
[SerializeField] private float _shakeMultiplierAtOne = 3f;
[Header("Debug")]
[SerializeField] private bool _debugMode;
// ─── State ─────────────────────────────────────────────────────────────────
private float _currentSanity;
private float _displayedSanity; // lerped value used for visual effects
private Coroutine _lerpCoroutine;
// External systems can suppress the hallucination vignette (e.g. diapason listening mode).
private bool _vignetteExternallyOverridden;
// Cached volume overrides
private ChromaticAberration _chromaticAberration;
private LensDistortion _lensDistortion;
private Vignette _vignette;
private FilmGrain _filmGrain;
private Bloom _bloom;
// ─── Public API ────────────────────────────────────────────────────────────
/// <summary>Current logical sanity level in [0, 1]. 0 = sane, 1 = fully insane.</summary>
public float SanityLevel => _currentSanity;
/// <summary>
/// Word shake multiplier computed from the lerped display sanity (smooth).
/// </summary>
public float WordShakeMultiplier =>
Mathf.Lerp(_shakeMultiplierAtZero, _shakeMultiplierAtOne, _displayedSanity);
/// <summary>Raised whenever the logical sanity level changes. Payload is the new value in [0, 1].</summary>
public event Action<float> OnSanityChanged;
/// <summary>Raised when a discrete hallucination event is triggered (e.g. ghost word appears).</summary>
public event Action<HallucinationEvent> OnHallucinationTriggered;
// ─── Lifecycle ─────────────────────────────────────────────────────────────
protected override void OnAwake()
{
_currentSanity = Mathf.Clamp(_initialSanity, MinSanity, MaxSanity);
_displayedSanity = _currentSanity;
CacheVolumeOverrides();
ApplyEffectsImmediate(_currentSanity);
}
private void Update()
{
if (_sanityRecoveryRate > 0f && _currentSanity > MinSanity)
SetSanity(_currentSanity - _sanityRecoveryRate * Time.deltaTime);
}
// ─── Public Methods ────────────────────────────────────────────────────────
/// <summary>
/// Increases the sanity level by <paramref name="amount"/> (clamped to [0, 1]).
/// Effects lerp smoothly to the new value.
/// </summary>
public void AddInsanity(float amount)
{
SetSanity(_currentSanity + amount);
}
/// <summary>
/// Directly sets the logical sanity level. Effects lerp smoothly to the new value.
/// </summary>
public void SetSanity(float value)
{
float clamped = Mathf.Clamp(value, MinSanity, MaxSanity);
if (Mathf.Approximately(clamped, _currentSanity))
return;
_currentSanity = clamped;
OnSanityChanged?.Invoke(_currentSanity);
if (_debugMode)
Debug.Log($"[HallucinationManager] Sanity → {_currentSanity:F2}", this);
RestartLerp();
}
/// <summary>
/// Suppresses the hallucination vignette so an external system (e.g. diapason) can
/// take full control of vignette without additive blending artefacts.
/// Call with <c>false</c> when the external system is done.
/// </summary>
public void SetVignetteOverriddenExternally(bool overridden)
{
_vignetteExternallyOverridden = overridden;
// Apply immediately so the vignette drops to zero without waiting for the lerp coroutine
if (_vignette != null)
_vignette.intensity.value = overridden ? 0f : Mathf.Lerp(_vignetteMin, _vignetteMax, _displayedSanity);
}
/// <summary>Fires a discrete hallucination event (ghost word, vision flash, etc.).</summary>
public void TriggerHallucinationEvent(HallucinationEvent hallucinationEvent)
{
if (hallucinationEvent == null)
{
Debug.LogError("[HallucinationManager] Attempted to trigger a null HallucinationEvent!", this);
return;
}
if (hallucinationEvent.SanityCost > 0f)
AddInsanity(hallucinationEvent.SanityCost);
if (_debugMode)
Debug.Log($"[HallucinationManager] Event: {hallucinationEvent.name}", this);
OnHallucinationTriggered?.Invoke(hallucinationEvent);
}
// ─── Lerp ──────────────────────────────────────────────────────────────────
private void RestartLerp()
{
if (_lerpCoroutine != null)
StopCoroutine(_lerpCoroutine);
_lerpCoroutine = StartCoroutine(LerpEffects());
}
private IEnumerator LerpEffects()
{
while (!Mathf.Approximately(_displayedSanity, _currentSanity))
{
_displayedSanity = Mathf.MoveTowards(_displayedSanity, _currentSanity, _effectLerpSpeed * Time.deltaTime);
ApplyEffectsImmediate(_displayedSanity);
yield return null;
}
_displayedSanity = _currentSanity;
ApplyEffectsImmediate(_displayedSanity);
_lerpCoroutine = null;
}
// ─── Effects ───────────────────────────────────────────────────────────────
private void CacheVolumeOverrides()
{
if (_hallucinationVolume == null || _hallucinationVolume.profile == null)
return;
_hallucinationVolume.profile.TryGet(out _chromaticAberration);
_hallucinationVolume.profile.TryGet(out _lensDistortion);
_hallucinationVolume.profile.TryGet(out _vignette);
_hallucinationVolume.profile.TryGet(out _filmGrain);
_hallucinationVolume.profile.TryGet(out _bloom);
// Keep volume weight at 1 permanently — we write individual override values directly.
// The hallucination volume must be on its OWN dedicated VolumeProfile, separate from
// the dialogue blur volume, to avoid weight conflicts between the two systems.
_hallucinationVolume.weight = 1f;
_hallucinationVolume.enabled = true;
}
private void ApplyEffectsImmediate(float t)
{
if (_driveChromaticAberration && _chromaticAberration != null)
_chromaticAberration.intensity.value = Mathf.Lerp(_chromaticAberrationMin, _chromaticAberrationMax, t);
if (_driveLensDistortion && _lensDistortion != null)
_lensDistortion.intensity.value = Mathf.Lerp(_lensDistortionMin, _lensDistortionMax, t);
if (_driveVignette && _vignette != null && !_vignetteExternallyOverridden)
_vignette.intensity.value = Mathf.Lerp(_vignetteMin, _vignetteMax, t);
if (_driveFilmGrain && _filmGrain != null)
_filmGrain.intensity.value = Mathf.Lerp(_filmGrainMin, _filmGrainMax, t);
if (_driveBloom && _bloom != null)
{
// Drive all bloom parameters that can independently cause it to appear.
// intensity alone is not enough — threshold=0 or scatter>0 can bleed through.
_bloom.intensity.value = Mathf.Lerp(_bloomIntensityMin, _bloomIntensityMax, t);
_bloom.threshold.value = Mathf.Lerp(_bloomThresholdMin, _bloomThresholdMax, t);
_bloom.scatter.value = Mathf.Lerp(_bloomScatterMin, _bloomScatterMax, t);
_bloom.dirtIntensity.value = Mathf.Lerp(_bloomDirtIntensityMin, _bloomDirtIntensityMax, t);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6806feb984393b441ad0f1b20cf70333
@@ -0,0 +1,126 @@
using UnityEngine;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Fires a hallucination trigger when a specific AudioSource plays, finishes, or reaches a timestamp.
/// Mirrors the pattern of ClueUnlockOnAudio but targets HallucinationManager.
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class HallucinationOnAudio : MonoBehaviour
{
public enum FireCondition
{
OnPlay,
OnFinish,
OnTimeReached
}
[Header("Audio Trigger")]
[Tooltip("When to fire the hallucination effect")]
[SerializeField] private FireCondition _fireCondition = FireCondition.OnPlay;
[Tooltip("Playback time in seconds to reach before firing (only for OnTimeReached)")]
[SerializeField] private float _triggerTime = 1f;
[Header("Hallucination Payload")]
[Tooltip("Amount of sanity to add when this fires (0 = no change)")]
[SerializeField, Range(0f, 1f)] private float _insanityAmount = 0f;
[Tooltip("Optional discrete hallucination event to fire")]
[SerializeField] private HallucinationEvent _hallucinationEvent;
[Header("Behaviour")]
[Tooltip("Prevent firing more than once per Play Mode session")]
[SerializeField] private bool _fireOnlyOnce = true;
[Header("Debug")]
[SerializeField] private bool _debugMode;
private AudioSource _audioSource;
private bool _hasFired;
private bool _isMonitoring;
private void Awake()
{
_audioSource = GetComponent<AudioSource>();
}
private void OnEnable()
{
_hasFired = false;
_isMonitoring = false;
if (_audioSource != null && _audioSource.isPlaying)
StartMonitoring();
}
private void Update()
{
if (!_isMonitoring || (_fireOnlyOnce && _hasFired))
return;
if (_audioSource == null || !_audioSource.isPlaying)
{
if (_fireCondition == FireCondition.OnFinish)
Execute();
_isMonitoring = false;
return;
}
if (_fireCondition == FireCondition.OnTimeReached && _audioSource.time >= _triggerTime)
Execute();
}
/// <summary>
/// Notify this component that the AudioSource has started playing.
/// Wire this to whatever code or event drives the AudioSource.
/// </summary>
public void OnAudioPlay()
{
StartMonitoring();
if (_fireCondition == FireCondition.OnPlay)
Execute();
}
/// <summary>
/// Resets the fired state so this can fire again.
/// </summary>
public void ResetFired()
{
_hasFired = false;
_isMonitoring = false;
}
private void StartMonitoring()
{
_isMonitoring = true;
}
private void Execute()
{
if (_fireOnlyOnce && _hasFired)
return;
_hasFired = true;
_isMonitoring = false;
if (HallucinationManager.Instance == null)
{
Debug.LogError("[HallucinationOnAudio] HallucinationManager not found!", this);
return;
}
if (_insanityAmount > 0f)
HallucinationManager.Instance.AddInsanity(_insanityAmount);
if (_hallucinationEvent != null)
HallucinationManager.Instance.TriggerHallucinationEvent(_hallucinationEvent);
if (_debugMode)
Debug.Log($"[HallucinationOnAudio] Fired on '{_fireCondition}'. Sanity now: {HallucinationManager.Instance.SanityLevel:F2}", this);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 57b4beb469df640499f96b38d4fa204b
@@ -0,0 +1,68 @@
using UnityEngine;
using Unheimlich.Core.Events;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Drives the HallucinationManager when its GameEventTrigger fires.
/// Can increase sanity, fire a discrete HallucinationEvent, or both.
/// Compose a GameEventTrigger on the same GameObject to control the firing condition
/// (OnStart, OnEnable, delay, once-flag, external call via UnityEvent, etc.).
/// </summary>
[RequireComponent(typeof(GameEventTrigger))]
public class HallucinationTrigger : MonoBehaviour
{
[Header("Sanity")]
[Tooltip("Amount of sanity to add when this trigger fires (0 = no change)")]
[SerializeField, Range(0f, 1f)] private float _insanityAmount = 0f;
[Header("Hallucination Event")]
[Tooltip("Optional discrete hallucination event to fire (ghost word, vision flash, etc.)")]
[SerializeField] private HallucinationEvent _hallucinationEvent;
[Header("Debug")]
[SerializeField] private bool _debugMode;
private GameEventTrigger _eventTrigger;
private void Awake()
{
_eventTrigger = GetComponent<GameEventTrigger>();
_eventTrigger.OnTriggered += Trigger;
}
private void OnDestroy()
{
if (_eventTrigger != null)
_eventTrigger.OnTriggered -= Trigger;
}
/// <summary>
/// Fires the hallucination effect. Can be called directly from external scripts or UnityEvents.
/// </summary>
public void Trigger()
{
if (HallucinationManager.Instance == null)
{
Debug.LogError("[HallucinationTrigger] HallucinationManager not found in scene!", this);
return;
}
if (_insanityAmount > 0f)
{
HallucinationManager.Instance.AddInsanity(_insanityAmount);
if (_debugMode)
Debug.Log($"[HallucinationTrigger] Added {_insanityAmount:F2} insanity. New sanity: {HallucinationManager.Instance.SanityLevel:F2}", this);
}
if (_hallucinationEvent != null)
{
HallucinationManager.Instance.TriggerHallucinationEvent(_hallucinationEvent);
if (_debugMode)
Debug.Log($"[HallucinationTrigger] Fired event: {_hallucinationEvent.Label}", this);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e1119e5c406d0346b0350dbcc91845f
@@ -0,0 +1,34 @@
using UnityEngine;
using Unheimlich.Core.Events;
namespace Unheimlich.Features.Hallucination
{
/// <summary>
/// Fires a GameEventTrigger (and therefore a HallucinationTrigger) when the player
/// enters a physics trigger collider. Pair with GameEventTrigger + HallucinationTrigger
/// on the same GameObject.
/// </summary>
[RequireComponent(typeof(GameEventTrigger))]
public class HallucinationTriggerZone : TriggerZoneBase
{
[Header("Zone Behaviour")]
[Tooltip("Allow the zone to fire again after the player exits and re-enters")]
[SerializeField] private bool _retriggerable = false;
protected override bool IsRetriggerable => _retriggerable;
private GameEventTrigger _eventTrigger;
protected override void Awake()
{
base.Awake();
_eventTrigger = GetComponent<GameEventTrigger>();
}
protected override void OnPlayerEntered(Collider playerCollider)
{
_eventTrigger.Fire();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e8fd091b4319d48448d266a24e885dae
@@ -0,0 +1,248 @@
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 39db1c5f2a13bc04782e676a6ad133c8