Hallucination system setup & implementation with the infinite staircase + Fake volumetric lights test

This commit is contained in:
Ferlat Thibaud
2026-03-27 20:47:11 +01:00
parent 34d8ffd5b0
commit 395f6266d6
119 changed files with 44996 additions and 816 deletions
@@ -3,41 +3,27 @@ using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using System;
using System.Collections;
using System.Collections.Generic;
using Unheimlich.Core;
using Unheimlich.Features.Dialogue.UI;
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")]
@@ -62,151 +48,175 @@ namespace Unheimlich.Features.Hallucination
[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, Min(0f)] private float _bloomIntensityMin = 0f;
[SerializeField, Min(0f)] private float _bloomIntensityMax = 1f;
[SerializeField, Min(0f)] private float _bloomThresholdMin = 1f;
[SerializeField, Min(0f)] private float _bloomThresholdMax = 0.5f;
[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;
[Header("Floating Label — Baseline Influence")]
[Tooltip("Labels within this radius of the player receive the sanity-driven baseline.")]
[SerializeField, Min(0f)] private float _labelInfluenceRadius = 12f;
[Tooltip("Max shake multiplier at sanity = 1")]
[SerializeField] private float _shakeMultiplierAtOne = 3f;
[Header("Floating Label — Shake")]
[Tooltip("Shake radius applied to labels at sanity = 0.")]
[SerializeField, Min(0f)] private float _labelShakeRadiusAtZero = 0f;
[SerializeField, Min(0f)] private float _labelShakeSpeedAtZero = 1.5f;
[SerializeField, Min(0f)] private float _labelShakeSmoothnessAtZero = 2f;
[Tooltip("Shake radius applied to labels at sanity = 1.")]
[SerializeField, Min(0f)] private float _labelShakeRadiusAtOne = 0.2f;
[SerializeField, Min(0f)] private float _labelShakeSpeedAtOne = 4f;
[SerializeField, Min(0f)] private float _labelShakeSmoothnessAtOne = 5f;
[Header("Floating Label — Ghost Trail")]
[SerializeField] private bool _ghostEnabledAtZero = false;
[SerializeField] private bool _ghostEnabledAtOne = true;
[SerializeField, Range(0f, 1f)] private float _ghostAlphaMin = 0.1f;
[SerializeField, Range(0f, 1f)] private float _ghostAlphaMax = 0.5f;
[Header("Word Shake (legacy — for other systems)")]
[SerializeField] private float _shakeMultiplierLegacyAtZero = 1f;
[SerializeField] private float _shakeMultiplierLegacyAtOne = 3f;
[Header("Debug")]
[SerializeField] private bool _debugMode;
// ─── State ─────────────────────────────────────────────────────────────────
// ─── State ─────────────────────────────────────────────────────────────
private float _currentSanity;
private float _displayedSanity; // lerped value used for visual effects
private float _currentSanity;
private float _displayedSanity;
private Coroutine _lerpCoroutine;
private bool _vignetteExternallyOverridden;
// 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;
private LensDistortion _lensDistortion;
private Vignette _vignette;
private FilmGrain _filmGrain;
private Bloom _bloom;
// ─── Public API ────────────────────────────────────────────────────────────
private Transform _playerTransform;
private readonly List<FloatingLabel> _labelsInRange = new();
// ─── 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);
Mathf.Lerp(_shakeMultiplierLegacyAtZero, _shakeMultiplierLegacyAtOne, _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<float> OnSanityChanged;
public event Action<HallucinationEvent> OnHallucinationTriggered;
// ─── Lifecycle ─────────────────────────────────────────────────────────────
// ─── Lifecycle ─────────────────────────────────────────────────────────
protected override void OnAwake()
{
_currentSanity = Mathf.Clamp(_initialSanity, MinSanity, MaxSanity);
_currentSanity = Mathf.Clamp(_initialSanity, MinSanity, MaxSanity);
_displayedSanity = _currentSanity;
CacheVolumeOverrides();
ApplyEffectsImmediate(_currentSanity);
ApplyPostProcessImmediate(_currentSanity);
}
private void Update()
{
if (_sanityRecoveryRate > 0f && _currentSanity > MinSanity)
SetSanity(_currentSanity - _sanityRecoveryRate * Time.deltaTime);
DriveFloatingLabelBaseline();
}
// ─── Public Methods ────────────────────────────────────────────────────────
// ─── 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);
}
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;
if (Mathf.Approximately(clamped, _currentSanity)) return;
_currentSanity = clamped;
OnSanityChanged?.Invoke(_currentSanity);
if (_debugMode)
Debug.Log($"[HallucinationManager] Sanity → {_currentSanity:F2}", this);
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);
_vignette.intensity.value = overridden
? 0f
: Mathf.Lerp(_vignetteMin, _vignetteMax, _displayedSanity);
}
/// <summary>Fires a discrete hallucination event (ghost word, vision flash, etc.).</summary>
/// <summary>
/// Fires a discrete hallucination event:
/// 1. Applies SanityCost.
/// 2. Dispatches per-label overrides defined in the event asset.
/// 3. Raises OnHallucinationTriggered.
/// </summary>
public void TriggerHallucinationEvent(HallucinationEvent hallucinationEvent)
{
if (hallucinationEvent == null)
{
Debug.LogError("[HallucinationManager] Attempted to trigger a null HallucinationEvent!", this);
Debug.LogError("[HallucinationManager] Null HallucinationEvent!", this);
return;
}
if (hallucinationEvent.SanityCost > 0f)
AddInsanity(hallucinationEvent.SanityCost);
hallucinationEvent.ApplyLabelOverrides();
if (_debugMode)
Debug.Log($"[HallucinationManager] Event: {hallucinationEvent.name}", this);
Debug.Log($"[HallucinationManager] Event: {hallucinationEvent.Label} " +
$"({hallucinationEvent.LabelOverrides.Count} label overrides)", this);
OnHallucinationTriggered?.Invoke(hallucinationEvent);
}
// ─── Lerp ──────────────────────────────────────────────────────────────────
// ─── Floating Label Baseline ───────────────────────────────────────────
private void DriveFloatingLabelBaseline()
{
EnsurePlayerTransform();
if (_playerTransform == null) return;
HallucinationLabelRegistry.GetLabelsInRadius(
_playerTransform.position, _labelInfluenceRadius, _labelsInRange);
float t = _displayedSanity;
float shakeRadius = Mathf.Lerp(_labelShakeRadiusAtZero, _labelShakeRadiusAtOne, t);
float shakeSpeed = Mathf.Lerp(_labelShakeSpeedAtZero, _labelShakeSpeedAtOne, t);
float shakeSmoothness = Mathf.Lerp(_labelShakeSmoothnessAtZero, _labelShakeSmoothnessAtOne, t);
bool ghostEnabled = t >= 0.5f ? _ghostEnabledAtOne : _ghostEnabledAtZero;
float ghostAlpha = Mathf.Lerp(_ghostAlphaMin, _ghostAlphaMax, t);
foreach (FloatingLabel label in _labelsInRange)
{
label.ApplyBaseline(shakeRadius, shakeSpeed, shakeSmoothness, ghostEnabled, ghostAlpha);
}
}
private void EnsurePlayerTransform()
{
if (_playerTransform != null) return;
if (Unheimlich.Features.Player.PlayerController.Instance != null)
_playerTransform = Unheimlich.Features.Player.PlayerController.Instance.transform;
else if (Camera.main != null)
_playerTransform = Camera.main.transform;
}
// ─── Lerp ──────────────────────────────────────────────────────────────
private void RestartLerp()
{
if (_lerpCoroutine != null)
StopCoroutine(_lerpCoroutine);
if (_lerpCoroutine != null) StopCoroutine(_lerpCoroutine);
_lerpCoroutine = StartCoroutine(LerpEffects());
}
@@ -214,22 +224,21 @@ namespace Unheimlich.Features.Hallucination
{
while (!Mathf.Approximately(_displayedSanity, _currentSanity))
{
_displayedSanity = Mathf.MoveTowards(_displayedSanity, _currentSanity, _effectLerpSpeed * Time.deltaTime);
ApplyEffectsImmediate(_displayedSanity);
_displayedSanity = Mathf.MoveTowards(
_displayedSanity, _currentSanity, _effectLerpSpeed * Time.deltaTime);
ApplyPostProcessImmediate(_displayedSanity);
yield return null;
}
_displayedSanity = _currentSanity;
ApplyEffectsImmediate(_displayedSanity);
ApplyPostProcessImmediate(_displayedSanity);
_lerpCoroutine = null;
}
// ─── Effects ───────────────────────────────────────────────────────────────
// ─── Post-Process Effects ──────────────────────────────────────────────
private void CacheVolumeOverrides()
{
if (_hallucinationVolume == null || _hallucinationVolume.profile == null)
return;
if (_hallucinationVolume == null || _hallucinationVolume.profile == null) return;
_hallucinationVolume.profile.TryGet(out _chromaticAberration);
_hallucinationVolume.profile.TryGet(out _lensDistortion);
@@ -237,20 +246,19 @@ namespace Unheimlich.Features.Hallucination
_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.weight = 1f;
_hallucinationVolume.enabled = true;
}
private void ApplyEffectsImmediate(float t)
private void ApplyPostProcessImmediate(float t)
{
if (_driveChromaticAberration && _chromaticAberration != null)
_chromaticAberration.intensity.value = Mathf.Lerp(_chromaticAberrationMin, _chromaticAberrationMax, t);
_chromaticAberration.intensity.value =
Mathf.Lerp(_chromaticAberrationMin, _chromaticAberrationMax, t);
if (_driveLensDistortion && _lensDistortion != null)
_lensDistortion.intensity.value = Mathf.Lerp(_lensDistortionMin, _lensDistortionMax, t);
_lensDistortion.intensity.value =
Mathf.Lerp(_lensDistortionMin, _lensDistortionMax, t);
if (_driveVignette && _vignette != null && !_vignetteExternallyOverridden)
_vignette.intensity.value = Mathf.Lerp(_vignetteMin, _vignetteMax, t);
@@ -260,13 +268,11 @@ namespace Unheimlich.Features.Hallucination
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.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);
}
}
}
}
}