Hallucination system
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
-51
@@ -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
|
||||
+38
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
+39
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e1119e5c406d0346b0350dbcc91845f
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8fd091b4319d48448d266a24e885dae
|
||||
+248
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user