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
@@ -158,7 +158,7 @@ MeshRenderer:
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 330dec4f7aa07334693179a8ae85c898, type: 2}
- {fileID: 2100000, guid: a4126e3e799efbc488e713f4ca63ab49, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
@@ -367,7 +367,6 @@ GameObject:
m_Component:
- component: {fileID: 3867316379480422796}
- component: {fileID: 1942933376864229506}
- component: {fileID: 1306787889985282719}
- component: {fileID: 1381459755705258711}
m_Layer: 0
m_Name: DetectiveBoardVirtualCamera
@@ -442,56 +441,6 @@ Camera:
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!114 &1306787889985282719
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6184255431701975184}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: df440cd4d3cc30d4e9f5b44ab4931a12, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Unheimlich.Gameplay.CameraControl.DetectiveBoardVirtualCamera
_virtualCamera: {fileID: 0}
_transitionSpeed: 3
_transitionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
_initialDistance: 1.5
_panSpeed: 2
_panSmoothTime: 0.2
_edgeBorderSize: 0.1
_maxPanOffset: {x: 1.5, y: 0.5}
_useDynamicPanLimits: 1
_panMargin: 0.05
_minDistance: 0.5
_maxDistance: 3
_zoomSpeed: 2
_zoomSmoothTime: 0.15
_scrollSensitivity: 0.3
--- !u!114 &1381459755705258711
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3720c98e23174d8418e04d8b445faed1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b4c627a455825134087ba8c6f8931aed, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Unheimlich.Features.Hallucination.HallucinationEvent
_label: 'RoomB Entry Vision '
_spawnGhostWord: 1
_ghostWordText: 'Ils savent '
_ghostWordSpawnOffset: {x: 0, y: 1.5, z: 2}
_ghostWordLifetime: 3
_sanityCost: 0.2
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 11ffb12bd28a98f40a40a63db49d7df3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-8730880457597845575
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0b2db86121404754db890f4c8dfe81b2, type: 3}
m_Name: Bloom
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.Bloom
active: 1
skipIterations:
m_OverrideState: 0
m_Value: 1
threshold:
m_OverrideState: 1
m_Value: 1
intensity:
m_OverrideState: 1
m_Value: 6.07
scatter:
m_OverrideState: 1
m_Value: 1
clamp:
m_OverrideState: 1
m_Value: 65472
tint:
m_OverrideState: 1
m_Value: {r: 0.27672952, g: 0.27672952, b: 0.27672952, a: 1}
highQualityFiltering:
m_OverrideState: 1
m_Value: 0
filter:
m_OverrideState: 1
m_Value: 1
downscale:
m_OverrideState: 0
m_Value: 0
maxIterations:
m_OverrideState: 0
m_Value: 6
dirtTexture:
m_OverrideState: 0
m_Value: {fileID: 0}
dimension: 1
dirtIntensity:
m_OverrideState: 0
m_Value: 0
--- !u!114 &-8301503217428336494
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 29fa0085f50d5e54f8144f766051a691, type: 3}
m_Name: FilmGrain
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.FilmGrain
active: 1
type:
m_OverrideState: 1
m_Value: 9
intensity:
m_OverrideState: 1
m_Value: 1
response:
m_OverrideState: 1
m_Value: 0.8
texture:
m_OverrideState: 0
m_Value: {fileID: 0}
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3}
m_Name: Hallucinations Volume Profile
m_EditorClassIdentifier: Unity.RenderPipelines.Core.Runtime::UnityEngine.Rendering.VolumeProfile
components:
- {fileID: 5048160815353275902}
- {fileID: 2293496333911105441}
- {fileID: 7109066975785212743}
- {fileID: -8301503217428336494}
- {fileID: -8730880457597845575}
--- !u!114 &2293496333911105441
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c5e1dc532bcb41949b58bc4f2abfbb7e, type: 3}
m_Name: LensDistortion
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.LensDistortion
active: 1
intensity:
m_OverrideState: 1
m_Value: -0.66
xMultiplier:
m_OverrideState: 0
m_Value: 1
yMultiplier:
m_OverrideState: 0
m_Value: 1
center:
m_OverrideState: 0
m_Value: {x: 0.5, y: 0.5}
scale:
m_OverrideState: 0
m_Value: 1
--- !u!114 &5048160815353275902
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 81180773991d8724ab7f2d216912b564, type: 3}
m_Name: ChromaticAberration
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.ChromaticAberration
active: 1
intensity:
m_OverrideState: 1
m_Value: 1
--- !u!114 &7109066975785212743
MonoBehaviour:
m_ObjectHideFlags: 3
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 899c54efeace73346a0a16faa3afe726, type: 3}
m_Name: Vignette
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.Vignette
active: 1
color:
m_OverrideState: 1
m_Value: {r: 0, g: 0, b: 0, a: 1}
center:
m_OverrideState: 0
m_Value: {x: 0.5, y: 0.5}
intensity:
m_OverrideState: 1
m_Value: 0.14
smoothness:
m_OverrideState: 1
m_Value: 0.385
rounded:
m_OverrideState: 0
m_Value: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1205a1bec127aa14aa7c48f120b84eb7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,96 @@
using UnityEngine;
using UnityEngine.Events;
using System;
namespace Unheimlich.Core.Events
{
/// <summary>
/// Generic reusable trigger component. Handles the once-flag, optional delay,
/// OnStart/OnEnable auto-fire, and exposes a UnityEvent + C# event for downstream systems.
/// Compose this on any GameObject that needs to fire a game event from multiple sources.
/// </summary>
public class GameEventTrigger : MonoBehaviour
{
[Header("Auto-fire")]
[Tooltip("Fire automatically on Start()")]
[SerializeField] private bool _fireOnStart;
[Tooltip("Fire automatically when this GameObject is enabled")]
[SerializeField] private bool _fireOnEnable;
[Header("Behaviour")]
[Tooltip("Delay in seconds before the event fires")]
[SerializeField] private float _delay;
[Tooltip("Prevent this trigger from firing more than once per Play Mode session")]
[SerializeField] private bool _fireOnlyOnce = true;
[Header("Callbacks")]
[Tooltip("Called when the trigger fires (after the optional delay)")]
[SerializeField] private UnityEvent _onTriggered;
/// <summary>C# event raised when the trigger fires. Useful for code-only listeners.</summary>
public event Action OnTriggered;
private bool _hasFired;
private bool _startFired;
private void OnEnable()
{
// Reset per-session so the trigger can fire again when entering Play Mode
// without Domain Reload.
_hasFired = false;
_startFired = false;
// NOTE: _fireOnEnable is intentionally deferred to Start() so all Singleton
// Awake() calls across the scene complete before listeners access managers.
}
private void Start()
{
if (_startFired)
return;
_startFired = true;
// Fire once even if both flags are ticked
if (_fireOnStart || _fireOnEnable)
Fire();
}
/// <summary>
/// Fires the trigger. Respects the once-flag and optional delay.
/// Call this from any external script, UnityEvent, or animation event.
/// </summary>
public void Fire()
{
if (_fireOnlyOnce && _hasFired)
return;
if (_delay > 0f)
Invoke(nameof(ExecuteTrigger), _delay);
else
ExecuteTrigger();
}
/// <summary>
/// Resets the fired state so the trigger can fire again.
/// </summary>
public void Reset()
{
_hasFired = false;
CancelInvoke(nameof(ExecuteTrigger));
}
private void ExecuteTrigger()
{
if (_fireOnlyOnce && _hasFired)
return;
_hasFired = true;
_onTriggered?.Invoke();
OnTriggered?.Invoke();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 358c75d041ea30a4ab85380893077e70
@@ -0,0 +1,87 @@
using UnityEngine;
using System;
namespace Unheimlich.Core.Events
{
/// <summary>
/// Abstract base for any physics-trigger zone that fires when the player enters.
/// Handles collider validation, player tag check, once-flag, retrigger, and Gizmos.
/// Subclasses implement OnPlayerEntered() and optionally OnPlayerExited().
/// </summary>
[RequireComponent(typeof(Collider))]
public abstract class TriggerZoneBase : MonoBehaviour
{
[Header("Zone Settings")]
[SerializeField] private string _playerTag = "Player";
[Header("Gizmo")]
[SerializeField] private Color _gizmoColor = new Color(0.8f, 0.5f, 0.0f, 0.35f);
protected bool HasFired { get; private set; }
private Collider _collider;
/// <summary>
/// Override to control whether the zone can fire again after the first trigger.
/// Default is false (fires once). Subclasses expose their own retrigger field and return it here.
/// </summary>
protected virtual bool IsRetriggerable => false;
protected virtual void Awake()
{
_collider = GetComponent<Collider>();
if (!_collider.isTrigger)
{
Debug.LogWarning($"[{GetType().Name}] Collider on '{gameObject.name}' is not a trigger. Forcing it.", this);
_collider.isTrigger = true;
}
}
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag(_playerTag))
return;
if (HasFired && !IsRetriggerable)
return;
HasFired = true;
OnPlayerEntered(other);
}
private void OnTriggerExit(Collider other)
{
if (!other.CompareTag(_playerTag))
return;
OnPlayerExited(other);
}
/// <summary>Called when the player enters the trigger zone.</summary>
protected abstract void OnPlayerEntered(Collider playerCollider);
/// <summary>Called when the player exits the trigger zone. Override if needed.</summary>
protected virtual void OnPlayerExited(Collider playerCollider) { }
/// <summary>Resets the fired state so the zone can fire again.</summary>
public void ResetZone()
{
HasFired = false;
}
private void OnDrawGizmosSelected()
{
Gizmos.color = _gizmoColor;
Gizmos.matrix = transform.localToWorldMatrix;
Collider col = GetComponent<Collider>();
if (col is BoxCollider box)
Gizmos.DrawCube(box.center, box.size);
else if (col is SphereCollider sphere)
Gizmos.DrawSphere(sphere.center, sphere.radius);
else if (col is CapsuleCollider capsule)
Gizmos.DrawWireSphere(capsule.center, capsule.radius);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9fae9f4c26344344e93baeaee0281284
@@ -41,6 +41,9 @@ namespace Unheimlich.Core.Interaction
private bool _isActive = false;
private bool _isMerged = false;
private bool _hasBeenMergedOnce = false;
// Persists across OnEnable/OnDisable — only cleared by explicit ResetMerge()
// or at the start of a new Play Mode session via RuntimeInitializeOnLoadMethod.
private bool _hasCompletedMergeOnce = false;
private Vector3[] _originalSpawnPositions;
private Vector3[] _basePositions;
@@ -51,7 +54,20 @@ namespace Unheimlich.Core.Interaction
void Start()
{
_playerTransform = PlayerController.Instance.transform;
InitializeReferences();
}
void OnEnable()
{
// With Scene Reload disabled, Start() doesn't run again between Play Mode sessions.
// Re-fetch references and reset transient state so animations restart cleanly.
InitializeReferences();
ResetSessionState();
}
private void InitializeReferences()
{
_playerTransform = PlayerController.Instance != null ? PlayerController.Instance.transform : null;
_mainCamera = Camera.main;
if (_playerTransform == null)
@@ -67,6 +83,41 @@ namespace Unheimlich.Core.Interaction
}
}
private void ResetSessionState()
{
// Force-despawn any leftover words from the previous session
// (their GOs were destroyed but _spawnedWords list may still have stale entries).
if (_isActive)
{
foreach (var word in _spawnedWords)
{
if (word != null) Destroy(word.gameObject);
}
_spawnedWords.Clear();
if (_wordsContainer != null) Destroy(_wordsContainer);
_wordsContainer = null;
_wordsRotator = null;
}
_isActive = false;
_isMerged = false;
_hasBeenMergedOnce = false;
// _hasCompletedMergeOnce intentionally NOT reset here — persists during session
// so a merged object stays merged when the player re-enters its activation range.
// It is only reset at the start of a new Play Mode session (see OnPlayModeReset).
_currentConvergenceRadius = 0f;
}
// Resets _hasCompletedMergeOnce at the very start of each Play Mode session,
// before any scene loads, so objects start fresh without full Domain Reload.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void OnPlayModeReset()
{
// Static reset — instance fields will be re-initialized by Awake/Start naturally.
// No instance access needed here; ResetSessionState() handles the rest on OnEnable.
}
void Update()
{
if (_playerTransform == null || _settings == null) return;
@@ -1,16 +1,45 @@
using UnityEngine;
using System;
using System.Collections.Generic;
namespace Unheimlich.Core
{
/// <summary>
/// Clears all Singleton static instances at the start of every Play Mode session.
/// Necessary when Domain Reload is disabled in Enter Play Mode settings.
/// </summary>
internal static class SingletonRegistry
{
private static readonly List<Action> _resetCallbacks = new List<Action>();
public static void Register(Action resetCallback)
{
if (!_resetCallbacks.Contains(resetCallback))
_resetCallbacks.Add(resetCallback);
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetAll()
{
foreach (Action reset in _resetCallbacks)
reset?.Invoke();
}
}
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
public static T Instance { get; private set; }
static Singleton()
{
SingletonRegistry.Register(() => Instance = null);
}
protected virtual void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogWarning($"[{typeof(T).Name}] Multiple instances detected! Destroying duplicate on GameObject '{gameObject.name}'.");
Debug.LogWarning($"[{typeof(T).Name}] Multiple instances detected! Destroying duplicate on '{gameObject.name}'.");
Destroy(this);
return;
}
@@ -18,6 +47,16 @@ namespace Unheimlich.Core
OnAwake();
}
// Re-registers the instance when entering Play Mode with Scene Reload disabled.
// SubsystemRegistration clears Instance before OnEnable runs, so we restore it here.
protected virtual void OnEnable()
{
if (Instance == null)
Instance = this as T;
OnEnabled();
}
protected virtual void OnDestroy()
{
if (Instance == this)
@@ -28,6 +67,7 @@ namespace Unheimlich.Core
}
protected virtual void OnAwake() { }
protected virtual void OnEnabled() { }
protected virtual void OnDestroyInstance() { }
}
}
@@ -0,0 +1,25 @@
using UnityEngine;
namespace Unheimlich.Core.Utils
{
/// <summary>
/// Resets third-party static state that doesn't handle Domain Reload being disabled.
/// Runs automatically at the start of every Play Mode session before the scene loads.
/// </summary>
internal static class GameSessionResetter
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void OnSessionStart()
{
ResetLeanTween();
}
private static void ResetLeanTween()
{
// LeanTween holds a static tween array that persists when Domain Reload is disabled.
// Stale tween IDs from the previous session cause callbacks to fire on wrong objects,
// which breaks animations like organic shake on floating words.
LeanTween.reset();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34edfe05577b2a24892a90d9901cef39
@@ -1,13 +1,13 @@
using UnityEngine;
using Unheimlich.Core.Events;
using Unheimlich.Features.DetectiveBoard.Data;
using Unheimlich.Features.DetectiveBoard;
namespace Unheimlich.Features.DetectiveBoard
{
/// <summary>
/// Generic trigger to unlock clues on the detective board.
/// Can be triggered by any game event: dialogue, object interaction, audio cue, hallucination, etc.
/// Simply call UnlockClue() when the trigger condition is met.
/// Unlocks a clue on the detective board.
/// Can be driven by an optional GameEventTrigger on the same GameObject,
/// or called directly via UnlockClue() from any external script or UnityEvent.
/// </summary>
public class ClueUnlockTrigger : MonoBehaviour
{
@@ -15,91 +15,84 @@ namespace Unheimlich.Features.DetectiveBoard
[Tooltip("The clue data to unlock when triggered")]
[SerializeField] private ClueData _clueToUnlock;
[Header("Trigger Settings")]
[Tooltip("Automatically unlock on Start()")]
[Header("Auto Trigger")]
[Tooltip("Unlock automatically on Start()")]
[SerializeField] private bool _unlockOnStart;
[Tooltip("Unlock when this GameObject is enabled")]
[SerializeField] private bool _unlockOnEnable;
[Tooltip("Delay before unlocking (useful for sequencing)")]
[Tooltip("Delay in seconds before unlocking (only with Unlock On Start)")]
[SerializeField] private float _unlockDelay;
[Tooltip("Show debug messages")]
[Tooltip("Prevent unlocking more than once")]
[SerializeField] private bool _unlockOnlyOnce = true;
[Header("Debug")]
[SerializeField] private bool _debugMode;
private GameEventTrigger _eventTrigger;
private bool _hasUnlocked;
/// <summary>Raised after the clue has been successfully unlocked.</summary>
public event System.Action OnClueUnlocked;
private void Awake()
{
// Optional composition with GameEventTrigger
_eventTrigger = GetComponent<GameEventTrigger>();
if (_eventTrigger != null)
_eventTrigger.OnTriggered += UnlockClue;
}
private void Start()
{
if (_unlockOnStart)
{
if (_unlockDelay > 0f)
Invoke(nameof(UnlockClue), _unlockDelay);
else
UnlockClue();
}
}
private void OnEnable()
private void OnDestroy()
{
if (_unlockOnEnable && !_hasUnlocked)
{
UnlockClue();
}
if (_eventTrigger != null)
_eventTrigger.OnTriggered -= UnlockClue;
}
/// <summary>
/// Unlocks the associated clue. Can be called from external scripts, events, or animations.
/// Unlocks the associated clue. Can be called directly from external scripts or UnityEvents.
/// </summary>
public void UnlockClue()
{
if (_hasUnlocked)
{
if (_debugMode)
Debug.LogWarning($"[ClueUnlockTrigger] Clue '{_clueToUnlock?.Title}' already unlocked!");
if (_unlockOnlyOnce && _hasUnlocked)
return;
}
if (_clueToUnlock == null)
{
Debug.LogError("[ClueUnlockTrigger] No clue data assigned!");
Debug.LogError("[ClueUnlockTrigger] No clue data assigned!", this);
return;
}
if (_unlockDelay > 0f)
if (DetectiveBoardManager.Instance == null)
{
Invoke(nameof(UnlockClueImmediate), _unlockDelay);
}
else
{
UnlockClueImmediate();
}
Debug.LogError("[ClueUnlockTrigger] DetectiveBoardManager not found!", this);
return;
}
private void UnlockClueImmediate()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
_hasUnlocked = true;
if (_debugMode)
Debug.Log($"[ClueUnlockTrigger] Unlocked clue: {_clueToUnlock.Title}");
Debug.Log($"[ClueUnlockTrigger] Unlocked clue: {_clueToUnlock.Title}", this);
OnClueUnlocked?.Invoke();
}
else
{
Debug.LogError("[ClueUnlockTrigger] DetectiveBoardManager not found!");
}
}
/// <summary>
/// Resets the unlock state (useful for testing).
/// </summary>
/// <summary>Resets the unlock state so the clue can be unlocked again.</summary>
public void ResetUnlock()
{
_hasUnlocked = false;
CancelInvoke(nameof(UnlockClue));
}
public event System.Action OnClueUnlocked;
}
}
@@ -138,6 +138,10 @@ namespace Unheimlich.Features.DetectiveBoard
DetectiveBoardCanvas.Instance.BoardRenderer
);
// Show clue hand fan when board is ready
if (ClueHandUI.Instance != null)
ClueHandUI.Instance.SetHandVisible(true);
if (_playerAlignmentSettings != null)
cameraPan.Settings = originalSettings;
});
@@ -159,6 +163,10 @@ namespace Unheimlich.Features.DetectiveBoard
_isBoardActive = false;
// Hide clue hand fan
if (ClueHandUI.Instance != null)
ClueHandUI.Instance.SetHandVisible(false);
DetectiveBoardVirtualCamera.Instance.Deactivate(() =>
{
var cameraPan = PlayerController.Instance?.CameraPanController;
@@ -0,0 +1,122 @@
using UnityEngine;
using UnityEngine.EventSystems;
using Unheimlich.Features.DetectiveBoard.Data;
namespace Unheimlich.Features.DetectiveBoard.UI
{
/// <summary>
/// Wraps a PostItNode to behave as a card in the bottom-screen fan hand.
/// Handles hover pop animation and delegates click to ClueHandUI for board placement.
/// Add this component alongside PostItNode on the same GameObject.
/// </summary>
[RequireComponent(typeof(PostItNode))]
public class ClueHandCard : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler
{
[Header("Hover Settings")]
[SerializeField] private float _hoverRiseAmount = 60f;
[SerializeField] private float _hoverAnimSpeed = 8f;
private PostItNode _node;
private ClueHandUI _handUI;
// Fan resting state assigned by ClueHandUI
private Vector2 _restAnchoredPosition;
private float _restRotation;
// Runtime animation state
private Vector2 _currentAnchoredPosition;
private float _currentRotation;
private bool _isHovered;
private bool _isPlaced;
public ClueData ClueData => _node != null ? _node.ClueData : null;
public PostItNode Node => _node;
public bool IsPlaced => _isPlaced;
private void Awake()
{
if (_node == null)
_node = GetComponent<PostItNode>();
}
/// <summary>
/// Binds this card to its parent hand manager.
/// </summary>
public void Initialize(PostItNode node, ClueHandUI handUI)
{
_node = node;
_handUI = handUI;
}
/// <summary>
/// Assigns the resting fan slot (position + rotation) for this card.
/// </summary>
public void SetFanSlot(Vector2 anchoredPosition, float rotationZ)
{
_restAnchoredPosition = anchoredPosition;
_restRotation = rotationZ;
_currentAnchoredPosition = anchoredPosition;
_currentRotation = rotationZ;
RectTransform rt = _node.RectTransform;
rt.anchoredPosition = anchoredPosition;
rt.localRotation = Quaternion.Euler(0f, 0f, rotationZ);
}
private void Update()
{
if (_isPlaced)
return;
RectTransform rt = _node.RectTransform;
Vector2 targetPos = _isHovered
? _restAnchoredPosition + Vector2.up * _hoverRiseAmount
: _restAnchoredPosition;
float targetRot = _isHovered ? 0f : _restRotation;
_currentAnchoredPosition = Vector2.Lerp(_currentAnchoredPosition, targetPos, Time.deltaTime * _hoverAnimSpeed);
_currentRotation = Mathf.LerpAngle(_currentRotation, targetRot, Time.deltaTime * _hoverAnimSpeed);
rt.anchoredPosition = _currentAnchoredPosition;
rt.localRotation = Quaternion.Euler(0f, 0f, _currentRotation);
}
public void OnPointerEnter(PointerEventData eventData)
{
if (_isPlaced)
return;
_isHovered = true;
_handUI?.OnCardHovered(this);
}
public void OnPointerExit(PointerEventData eventData)
{
if (_isPlaced)
return;
_isHovered = false;
_handUI?.OnCardUnhovered(this);
}
public void OnPointerClick(PointerEventData eventData)
{
if (_isPlaced || eventData.button != PointerEventData.InputButton.Left)
return;
_isHovered = false;
_handUI?.OnCardSelected(this);
}
/// <summary>
/// Hides this card from the hand after placement on the board.
/// </summary>
public void MarkAsPlaced()
{
_isPlaced = true;
gameObject.SetActive(false);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dc7ce79e8bf16554fae5362980fec5b7
@@ -0,0 +1,252 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Core;
using Unheimlich.Features.DetectiveBoard.Data;
namespace Unheimlich.Features.DetectiveBoard.UI
{
/// <summary>
/// Manages the clue hand fan displayed at the bottom of the screen (Screen Space canvas).
/// Reuses the PostItNode prefab for each card — ClueHandCard is added at runtime.
/// Hovering a card pops it up and straightens it; clicking starts a lerp-depth
/// placement animation then hands the clue to DetectiveBoardCanvas.
/// </summary>
public class ClueHandUI : Singleton<ClueHandUI>
{
[Header("References")]
[SerializeField] private GameObject _postItNodePrefab;
[SerializeField] private RectTransform _handContainer;
[Header("Fan Layout Settings")]
[SerializeField] private float _cardSpacingPixels = 80f;
[SerializeField] private float _fanArcRadius = 800f;
[SerializeField] private float _cardBaseY = -120f;
[SerializeField] private float _maxFanHalfAngle = 40f;
[Header("Placement Settings")]
[SerializeField] private float _placementLerpSpeed = 5f;
private readonly List<ClueHandCard> _handCards = new List<ClueHandCard>();
private bool _isPlacing; protected override void OnAwake() { }
protected override void OnEnabled()
{
// With Scene Reload disabled, stale card references from the previous session
// may point to destroyed GameObjects. Clear and rebuild from manager state.
if (_handCards.Count > 0)
{
foreach (ClueHandCard card in _handCards)
{
if (card != null && card.gameObject != null)
Destroy(card.gameObject);
}
_handCards.Clear();
}
if (DetectiveBoardManager.Instance != null)
{
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
{
// Skip clues already placed on the board — they must not reappear in the hand
if (DetectiveBoardCanvas.Instance != null && DetectiveBoardCanvas.Instance.HasNodeForClue(clue.ClueID))
continue;
AddClueCard(clue);
}
}
}
private void Start()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked += OnClueUnlocked;
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
AddClueCard(clue);
}
// Start hidden — shown when the board activates
SetHandVisible(false);
}
private void OnDestroy()
{
if (DetectiveBoardManager.Instance != null)
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
}
private void OnClueUnlocked(ClueData clueData)
{
AddClueCard(clueData);
}
// ──────────────────────────────────────────────────────────────────────
// Card management
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Instantiates a PostItNode prefab, injects ClueHandCard, and adds it to the fan.
/// </summary>
public void AddClueCard(ClueData clueData)
{
if (_postItNodePrefab == null || _handContainer == null)
{
Debug.LogError("[ClueHandUI] Missing _postItNodePrefab or _handContainer reference.");
return;
}
if (_handCards.Exists(c => c.ClueData != null && c.ClueData.ClueID == clueData.ClueID))
return;
// Instantiate the PostItNode prefab inside the screen-space hand container
GameObject cardObj = Instantiate(_postItNodePrefab, _handContainer);
// PostItNode is nested one level inside "Canvas (Environment)" wrapper in the prefab
// — grab it directly
PostItNode node = cardObj.GetComponentInChildren<PostItNode>(true);
if (node == null)
{
Debug.LogError("[ClueHandUI] PostItNode not found on prefab.");
Destroy(cardObj);
return;
}
// Create a dummy NodeData for the hand display (position handled by fan layout)
PostItNodeData dummyData = new PostItNodeData(clueData.ClueID, Vector2.zero);
node.Initialize(clueData, dummyData);
// Add ClueHandCard wrapper on the PostItNode GameObject
ClueHandCard card = node.gameObject.AddComponent<ClueHandCard>();
card.Initialize(node, this);
// Move the PostItNode GameObject up to be a direct child of _handContainer
// so its RectTransform is in screen space
node.transform.SetParent(_handContainer, false);
// Destroy the now-empty prefab root (the "Canvas (Environment)" wrapper)
if (cardObj != node.gameObject)
Destroy(cardObj);
// Normalize scale — PostItNode prefab uses 0.5 scale for world space, reset for screen space
node.RectTransform.localScale = Vector3.one;
_handCards.Add(card);
RebuildFanLayout();
}
/// <summary>
/// Recomputes fan arc positions and rotations for all non-placed cards.
/// Uses fixed pixel spacing between cards, capped by a max half-angle.
/// </summary>
private void RebuildFanLayout()
{
List<ClueHandCard> visible = _handCards.FindAll(c => !c.IsPlaced);
int count = visible.Count;
if (count == 0)
return;
// Derive the angle step from pixel spacing on the arc
float angleStepDeg = (_cardSpacingPixels / _fanArcRadius) * Mathf.Rad2Deg;
// Total spread is (count-1) steps, capped so it never exceeds _maxFanHalfAngle on each side
float totalHalfAngle = angleStepDeg * (count - 1) * 0.5f;
float clampedHalfAngle = Mathf.Min(totalHalfAngle, _maxFanHalfAngle);
for (int i = 0; i < count; i++)
{
float t = count == 1 ? 0.5f : (float)i / (count - 1);
float angle = Mathf.Lerp(-clampedHalfAngle, clampedHalfAngle, t);
float angleRad = angle * Mathf.Deg2Rad;
float x = _fanArcRadius * Mathf.Sin(angleRad);
float y = _cardBaseY - _fanArcRadius * (1f - Mathf.Cos(angleRad));
visible[i].SetFanSlot(new Vector2(x, y), -angle);
}
}
// ──────────────────────────────────────────────────────────────────────
// Hover callbacks
// ──────────────────────────────────────────────────────────────────────
/// <summary>Brings the hovered card in front of its siblings.</summary>
public void OnCardHovered(ClueHandCard card)
{
card.transform.SetAsLastSibling();
}
/// <summary>Restores natural sibling order after hover ends.</summary>
public void OnCardUnhovered(ClueHandCard card)
{
RebuildSiblingOrder();
}
private void RebuildSiblingOrder()
{
int index = 0;
foreach (ClueHandCard c in _handCards)
{
if (!c.IsPlaced)
c.transform.SetSiblingIndex(index++);
}
}
// ──────────────────────────────────────────────────────────────────────
// Selection & placement
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Called when the player clicks a card. Creates the node on the board
/// directly under the mouse cursor and immediately starts drag mode.
/// </summary>
public void OnCardSelected(ClueHandCard card)
{
if (_isPlacing)
return;
DetectiveBoardCanvas boardCanvas = DetectiveBoardCanvas.Instance;
if (boardCanvas == null)
{
Debug.LogWarning("[ClueHandUI] DetectiveBoardCanvas not found.");
return;
}
DetectiveBoardInteraction interaction = DetectiveBoardInteraction.Instance;
if (interaction == null)
{
Debug.LogWarning("[ClueHandUI] DetectiveBoardInteraction not found.");
return;
}
boardCanvas.AddClueNode(card.ClueData);
PostItNode boardNode = boardCanvas.GetNodeForClue(card.ClueData.ClueID);
if (boardNode == null)
{
Debug.LogWarning("[ClueHandUI] Node not found on board after AddClueNode.");
return;
}
// Position node under the mouse on the board canvas plane, then start drag
interaction.SpawnNodeUnderMouseAndDrag(boardNode);
card.MarkAsPlaced();
RebuildFanLayout();
}
// ──────────────────────────────────────────────────────────────────────
// Visibility
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Shows or hides the fan hand. Called by DetectiveBoardController.
/// </summary>
public void SetHandVisible(bool visible)
{
if (_handContainer != null)
_handContainer.gameObject.SetActive(visible);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ace0b6173dad68f47a97505680f54d4e
@@ -131,45 +131,11 @@ namespace Unheimlich.Features.DetectiveBoard.UI
_saveData = new DetectiveBoardSaveData();
}
private void OnEnable()
{
TrySubscribeToManager();
}
private void OnEnable() { }
private void Start()
{
TrySubscribeToManager();
}
private void Start() { }
private void TrySubscribeToManager()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
DetectiveBoardManager.Instance.OnClueUnlocked += OnClueUnlocked;
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
{
if (!_activeNodes.ContainsKey(clue.ClueID))
{
AddClueNode(clue);
}
}
}
}
private void OnDisable()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
}
}
private void OnClueUnlocked(ClueData clueData)
{
AddClueNode(clueData);
}
private void OnDisable() { }
private void Update()
{
@@ -413,6 +379,19 @@ namespace Unheimlich.Features.DetectiveBoard.UI
}
}
/// <summary>
/// Returns the active PostItNode for the given clue ID, or null if not found.
/// Used by ClueHandUI to retrieve the node after placement.
/// </summary>
/// <summary>Returns true if a node for this clue ID is already placed on the board.</summary>
public bool HasNodeForClue(string clueID) => _activeNodes.ContainsKey(clueID);
public PostItNode GetNodeForClue(string clueID)
{
_activeNodes.TryGetValue(clueID, out PostItNode node);
return node;
}
public DetectiveBoardSaveData GetSaveData()
{
return _saveData;
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using Unheimlich.Core;
using Unheimlich.Features.DetectiveBoard;
using Unheimlich.Features.Player;
using Unheimlich.World.CameraSystem;
@@ -14,7 +15,7 @@ namespace Unheimlich.Features.DetectiveBoard.UI
/// Manages hover, selection, dragging, and click forwarding for Post-It nodes.
/// Interactions are only active when the board is in inspection mode.
/// </summary>
public class DetectiveBoardInteraction : MonoBehaviour
public class DetectiveBoardInteraction : Singleton<DetectiveBoardInteraction>
{
[Header("Settings")]
[SerializeField] private LayerMask _boardLayer;
@@ -33,6 +34,8 @@ namespace Unheimlich.Features.DetectiveBoard.UI
private Plane _canvasPlane;
private Vector3 _dragOffset;
protected override void OnAwake() { }
private void Start()
{
_boardCanvas = GetComponent<Canvas>();
@@ -349,6 +352,41 @@ namespace Unheimlich.Features.DetectiveBoard.UI
}
}
/// <summary>
/// Positions a freshly-created board node under the current mouse cursor
/// and immediately enters drag mode on it, so the player can place it freely.
/// Called by ClueHandUI after AddClueNode.
/// </summary>
public void SpawnNodeUnderMouseAndDrag(PostItNode node)
{
if (node == null || _camera == null)
return;
UpdateCanvasPlane();
Vector2 mousePos = Mouse.current != null
? Mouse.current.position.ReadValue()
: new Vector2(Screen.width * 0.5f, Screen.height * 0.5f);
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0f));
if (_canvasPlane.Raycast(ray, out float enter))
{
Vector3 worldHitPoint = ray.GetPoint(enter);
Vector3 localPos = _boardCanvas.transform.InverseTransformPoint(worldHitPoint);
node.SetPosition(new Vector2(localPos.x, localPos.y));
}
// Begin drag with zero offset so the node centre tracks the mouse exactly
if (_selectedNode != null)
_selectedNode.SetSelected(false);
_selectedNode = node;
_selectedNode.SetSelected(true);
_isDragging = true;
_dragOffset = Vector3.zero;
}
private PostItNode RaycastForNode()
{
if (_camera == null || Mouse.current == null)
@@ -53,8 +53,35 @@ namespace Unheimlich.Features.Dialogue
private float _autoCloseTimer = 0f;
private bool _isWaitingForAutoClose = false;
protected override void OnAwake()
protected override void OnAwake() { }
protected override void OnEnabled()
{
// With Scene Reload disabled, Start() doesn't run again each session.
// We recreate the focus system to clear any stale locked state (_isFocusLocked,
// _focusTimer, _currentFocusedHandler) that may have carried over.
if (_focusSystem != null)
{
_mainCamera = Camera.main;
_focusSystem = new DialogueFocusSystem(
this,
_mainCamera,
PlayerController.Instance?.CameraEffects,
_choiceLayer,
_focusRange,
_focusTime,
_raycastInterval,
_recoveryDuration,
_focusRetentionTime,
_fallbackShakeIntensity
);
_focusSystem.OnChoiceMade += (choice) => OnChoiceMade?.Invoke(choice);
// Also reset dialogue state in case we exited mid-dialogue
IsInDialogue = false;
_isWaitingForAutoClose = false;
_autoCloseTimer = 0f;
}
}
private void Start()
@@ -82,6 +109,13 @@ namespace Unheimlich.Features.Dialogue
if (!IsInDialogue)
return;
if (_activeDialogueUI == null)
{
Debug.LogError("[DialogueManager] IsInDialogue is true but _activeDialogueUI is null. Forcing dialogue end.", this);
IsInDialogue = false;
return;
}
if (!_activeDialogueUI.AreChoicesVisible())
{
if (_isWaitingForAutoClose && _currentNode != null && _currentNode.AutoCloseDelay > 0f)
@@ -142,6 +176,12 @@ namespace Unheimlich.Features.Dialogue
_activeDialogueUI = customDialogueUI;
CurrentOwner = owner;
if (_activeDialogueUI == null)
{
Debug.LogError("[DialogueManager] StartConversation called with no DialogueUI. Assign a Local Dialogue UI on the DialogueTriggerZone that triggered this conversation.", this);
return;
}
ShowCurrentNode();
}
@@ -2,6 +2,7 @@ using UnityEngine;
using Unheimlich.Features.Dialogue.Data;
using Unheimlich.Features.Dialogue;
using Unheimlich.Features.Dialogue.UI;
using Unheimlich.Core.Events;
namespace Unheimlich.Features.Dialogue
{
@@ -9,9 +10,8 @@ namespace Unheimlich.Features.Dialogue
/// 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
{
public class DialogueTriggerZone : TriggerZoneBase
{
[Header("Dialogue Reference")]
[Tooltip("DialogueConversation to use for this trigger zone")]
[SerializeField] private DialogueConversation _conversation;
@@ -19,8 +19,8 @@ namespace Unheimlich.Features.Dialogue
[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";
protected override bool IsRetriggerable => _isRetriggerable;
[Header("Distance Check")]
[Tooltip("Pause dialogue if player exits the trigger zone")]
@@ -41,29 +41,19 @@ namespace Unheimlich.Features.Dialogue
[SerializeField] private bool _enableBlurEffect = true;
private bool _hasTriggered = false;
private Collider _triggerCollider;
private DialogueStateHandler _dialogueState;
private void Awake()
protected override void Awake()
{
_triggerCollider = GetComponent<Collider>();
base.Awake();
_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)
protected override void OnPlayerEntered(Collider playerCollider)
{
if (_requirePlayerTag && !other.CompareTag(_playerTag))
return;
if (_dialogueState.IsCompleted && !_isRetriggerable)
return;
@@ -80,11 +70,8 @@ namespace Unheimlich.Features.Dialogue
}
}
private void OnTriggerExit(Collider other)
protected override void OnPlayerExited(Collider playerCollider)
{
if (_requirePlayerTag && !other.CompareTag(_playerTag))
return;
if (_pauseOnExit && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
{
if ((Object)DialogueManager.Instance.CurrentOwner == this)
@@ -153,6 +140,7 @@ namespace Unheimlich.Features.Dialogue
{
_hasTriggered = false;
_dialogueState.Reset();
ResetZone();
}
private void OnDestroy()
@@ -161,20 +149,14 @@ namespace Unheimlich.Features.Dialogue
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
private void OnDrawGizmosSelected()
private void OnValidate()
{
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 (_localDialogueUI == null)
Debug.LogWarning($"[DialogueTriggerZone] '{gameObject.name}' has no Local Dialogue UI assigned. Assign the appropriate DialogueUI prefab variant (World, NPC, Room, Screen, etc.).", this);
}
private void OnDrawGizmosSelected()
{
if (_floatingWordAnchor != null)
{
Gizmos.color = Color.cyan;
@@ -253,7 +253,8 @@ namespace Unheimlich.Features.Dialogue.UI
{
if (!_positionCaptured)
{
_startLocalPos = transform.localPosition;
// Subtract shake offset to get the clean base position, not the shaken one
_startLocalPos = transform.localPosition - _shakeOffset;
_positionCaptured = true;
LeanTween.cancel(gameObject);
@@ -516,13 +517,15 @@ namespace Unheimlich.Features.Dialogue.UI
ResetSketchFade();
TweenHelper.MoveLocal(gameObject, _startLocalPos, duration, () => {
// Reset position capture and restart shake immediately — don't rely on a LeanTween
// callback for this, since stale tween IDs (Domain Reload disabled) can silently
// drop callbacks, leaving some words permanently non-shaking.
_positionCaptured = false;
StartOrganicShake();
TweenHelper.MoveLocal(gameObject, _startLocalPos, duration, () => {
if (DialogueManager.Instance != null)
DialogueManager.Instance.UnlockFocus();
StartOrganicShake();
}, LeanTweenType.easeOutQuad);
transform.localScale = Vector3.one * _originalScale;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 61bef91a8c6020442b3c036173f37714
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -2,6 +2,7 @@ using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using Unheimlich.Core;
using Unheimlich.Features.Hallucination;
namespace Unheimlich.Features.Player
{
@@ -75,6 +76,11 @@ namespace Unheimlich.Features.Player
public void EnableVignette(bool enable)
{
_isEnabled = enable;
// Suppress the hallucination vignette while diapason is active to avoid additive blending.
// Restores hallucination vignette control when diapason listening ends.
if (HallucinationManager.Instance != null)
HallucinationManager.Instance.SetVignetteOverriddenExternally(enable);
}
public void SetIntensity(float intensity)
@@ -222,38 +222,34 @@ namespace Unheimlich.World.CameraSystem
if (Mouse.current != null)
{
Vector2 mousePos = Mouse.current.position.ReadValue();
bool mouseHasMoved = Vector2.Distance(mousePos, _lastMousePosition) > 0.1f;
_lastMousePosition = mousePos;
if (mouseHasMoved)
{
float screenWidth = Screen.width;
float screenHeight = Screen.height;
float borderPixels = _edgeBorderSize * Mathf.Min(screenWidth, screenHeight);
if (mousePos.x < borderPixels)
if (mousePos.x >= 0f && mousePos.x < borderPixels)
{
panX = -(borderPixels - mousePos.x) / borderPixels;
hasInput = true;
}
else if (mousePos.x > screenWidth - borderPixels)
else if (mousePos.x > screenWidth - borderPixels && mousePos.x <= screenWidth)
{
panX = (mousePos.x - (screenWidth - borderPixels)) / borderPixels;
hasInput = true;
}
if (mousePos.y < borderPixels)
if (mousePos.y >= 0f && mousePos.y < borderPixels)
{
panY = -(borderPixels - mousePos.y) / borderPixels;
hasInput = true;
}
else if (mousePos.y > screenHeight - borderPixels)
else if (mousePos.y > screenHeight - borderPixels && mousePos.y <= screenHeight)
{
panY = (mousePos.y - (screenHeight - borderPixels)) / borderPixels;
hasInput = true;
}
}
}
if (hasInput)
{