Smooth 3C & simplify hallucination trigger

This commit is contained in:
Ferlat Thibaud
2026-03-22 18:03:21 +01:00
parent 0d968f75ab
commit 1589a7a543
24 changed files with 1318 additions and 621 deletions
@@ -0,0 +1,156 @@
using System.Collections;
using UnityEngine;
namespace Unheimlich.Core.Audio
{
/// <summary>
/// Base class for components that react to an AudioSource's playback state.
/// Supports three fire conditions: OnPlay, OnFinish, and OnTimeReached.
/// Subclasses implement Execute() to define what happens when the condition is met.
/// </summary>
[RequireComponent(typeof(AudioSource))]
public abstract class AudioTriggerBase : MonoBehaviour
{
public enum FireCondition
{
/// <summary>Fire as soon as the audio starts playing.</summary>
OnPlay,
/// <summary>Fire when the audio clip finishes.</summary>
OnFinish,
/// <summary>Fire when playback reaches a specific timestamp.</summary>
OnTimeReached
}
[Header("Audio Trigger")]
[Tooltip("When to fire the effect")]
[SerializeField] protected FireCondition _fireCondition = FireCondition.OnPlay;
[Tooltip("Playback time in seconds that triggers the effect (OnTimeReached only)")]
[SerializeField] protected float _triggerTime = 1f;
[Header("Behaviour")]
[Tooltip("Prevent firing more than once per session")]
[SerializeField] protected bool _fireOnlyOnce = true;
[Header("Debug")]
[SerializeField] protected bool _debugMode;
protected AudioSource AudioSource { get; private set; }
private bool _hasFired;
private bool _isMonitoring;
private Coroutine _finishWatcher;
protected virtual void Awake()
{
AudioSource = GetComponent<AudioSource>();
}
protected virtual void OnEnable()
{
_hasFired = false;
_isMonitoring = false;
if (AudioSource != null && AudioSource.isPlaying)
StartMonitoring();
}
protected virtual void OnDisable()
{
StopMonitoring();
}
// ─── Public API ────────────────────────────────────────────────────────────
/// <summary>
/// Notify this component that the AudioSource has started playing.
/// Wire this to whatever code or event starts the AudioSource.
/// </summary>
public void OnAudioPlay()
{
StartMonitoring();
if (_fireCondition == FireCondition.OnPlay)
TryExecute();
}
/// <summary>Resets the fired state so this can fire again.</summary>
public void ResetFired()
{
_hasFired = false;
StopMonitoring();
}
// ─── Monitoring ────────────────────────────────────────────────────────────
private void StartMonitoring()
{
_isMonitoring = true;
if (_fireCondition == FireCondition.OnFinish)
{
StopMonitoring();
_finishWatcher = StartCoroutine(WatchForFinish());
}
}
private void StopMonitoring()
{
_isMonitoring = false;
if (_finishWatcher != null)
{
StopCoroutine(_finishWatcher);
_finishWatcher = null;
}
}
private void Update()
{
if (!_isMonitoring || (_fireOnlyOnce && _hasFired))
return;
if (_fireCondition != FireCondition.OnTimeReached)
return;
if (AudioSource != null && AudioSource.isPlaying && AudioSource.time >= _triggerTime)
TryExecute();
}
// Uses a coroutine instead of Update polling so the finish detection is frame-accurate
// even for very short clips that could end between two Update calls.
private IEnumerator WatchForFinish()
{
if (AudioSource == null)
yield break;
// Wait until the clip actually has a length to work with
yield return null;
if (AudioSource.clip != null)
yield return new WaitForSeconds(AudioSource.clip.length);
else
yield return new WaitUntil(() => !AudioSource.isPlaying);
TryExecute();
_finishWatcher = null;
}
// ─── Internal ──────────────────────────────────────────────────────────────
private void TryExecute()
{
if (_fireOnlyOnce && _hasFired)
return;
_hasFired = true;
StopMonitoring();
Execute();
}
/// <summary>
/// Called when the fire condition is met. Implement the payload here.
/// </summary>
protected abstract void Execute();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 16d04d63a4903d54dbd0eee78c2ba121
@@ -22,23 +22,23 @@ namespace Unheimlich.Core.Interaction
_playerCamera = GetComponent<Camera>();
}
private bool _didHit;
private void Update()
{
if (RaycastHelper.ThrottledRaycastComponent(
ref _raycastTimer,
_didHit = RaycastHelper.ThrottledRaycastComponent(
ref _raycastTimer,
_raycastInterval,
_playerCamera,
_interactionRange,
_interactableLayers,
out InteractableBase interactable))
{
_playerCamera,
_interactionRange,
_interactableLayers,
out InteractableBase interactable);
if (_didHit)
_currentInteractable = interactable;
}
else if (_raycastTimer == 0f)
{
else if (!_didHit && _raycastTimer <= 0f)
_currentInteractable = null;
}
HandleInteractionInput();
}
@@ -85,8 +85,6 @@ 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)
@@ -103,19 +101,19 @@ namespace Unheimlich.Core.Interaction
_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).
_hasCompletedMergeOnce = false; // reset so MergeOnce objects start fresh each session
_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.
// Resets _hasCompletedMergeOnce at the very start of each Play Mode session so
// merged objects start fresh without requiring a 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.
// Finding all instances isn't possible from a static context with SubsystemRegistration.
// The reset is handled per-instance in OnEnable via _hasCompletedMergeOnce being
// re-initialized when the scene reloads. For Enter Play Mode without scene reload,
// ResetSessionState() is called from OnEnable and now correctly clears the flag.
}
void Update()
@@ -1,115 +1,40 @@
using UnityEngine;
using Unheimlich.Core.Audio;
using Unheimlich.Features.DetectiveBoard.Data;
using Unheimlich.Features.DetectiveBoard;
namespace Unheimlich.Features.DetectiveBoard
{
/// <summary>
/// Unlocks a clue when a specific audio source plays or finishes playing.
/// Useful for triggering clues based on environmental sounds, dialogue, or hallucinations.
/// Unlocks a clue when a specific AudioSource plays, finishes, or reaches a timestamp.
/// Call OnAudioPlay() from whatever drives the AudioSource to start monitoring.
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class ClueUnlockOnAudio : MonoBehaviour
public class ClueUnlockOnAudio : AudioTriggerBase
{
public enum UnlockTrigger
{
OnPlay,
OnFinish,
OnTimeReached
}
[Header("Clue Configuration")]
[Tooltip("The clue to unlock")]
[SerializeField] private ClueData _clueToUnlock;
[Header("Audio Trigger")]
[Tooltip("When to unlock the clue")]
[SerializeField] private UnlockTrigger _trigger = UnlockTrigger.OnPlay;
[Tooltip("Time in seconds to wait before unlocking (only for OnTimeReached)")]
[SerializeField] private float _unlockTime = 1f;
private AudioSource _audioSource;
private bool _hasUnlocked;
private bool _isMonitoring;
private float _currentTime;
private void Awake()
protected override void Execute()
{
_audioSource = GetComponent<AudioSource>();
}
private void OnEnable()
{
if (_audioSource != null && _audioSource.isPlaying)
if (_clueToUnlock == null)
{
StartMonitoring();
}
}
private void Update()
{
if (!_isMonitoring || _hasUnlocked)
return;
if (_audioSource == null || !_audioSource.isPlaying)
{
if (_trigger == UnlockTrigger.OnFinish && _isMonitoring)
{
UnlockClue();
}
_isMonitoring = false;
Debug.LogError("[ClueUnlockOnAudio] No ClueData assigned!", this);
return;
}
_currentTime = _audioSource.time;
if (_trigger == UnlockTrigger.OnTimeReached && _currentTime >= _unlockTime)
if (DetectiveBoardManager.Instance == null)
{
UnlockClue();
}
}
/// <summary>
/// Call this when the audio starts playing.
/// </summary>
public void OnAudioPlay()
{
StartMonitoring();
if (_trigger == UnlockTrigger.OnPlay)
{
UnlockClue();
}
}
private void StartMonitoring()
{
_isMonitoring = true;
_currentTime = 0f;
}
private void UnlockClue()
{
if (_hasUnlocked || _clueToUnlock == null)
Debug.LogError("[ClueUnlockOnAudio] DetectiveBoardManager not found!", this);
return;
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
_hasUnlocked = true;
Debug.Log($"[ClueUnlockOnAudio] Unlocked clue: {_clueToUnlock.Title} via audio trigger");
}
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
if (_debugMode)
Debug.Log($"[ClueUnlockOnAudio] Unlocked clue '{_clueToUnlock.Title}' on '{_fireCondition}'.", this);
}
/// <summary>
/// Resets the unlock state.
/// </summary>
public void ResetUnlock()
{
_hasUnlocked = false;
_isMonitoring = false;
}
/// <summary>Resets the unlock state so the clue can be unlocked again.</summary>
public void ResetUnlock() => ResetFired();
}
}
@@ -39,6 +39,8 @@ namespace Unheimlich.Features.Dialogue
public DialogueConversation CurrentConversation => _currentConversation;
public DialogueNode CurrentNode => _currentNode;
private bool _blurEnabled;
public event Action<DialogueChoiceData> OnChoiceMade;
public event Action<object> OnDialogueCompleted;
@@ -132,11 +134,9 @@ namespace Unheimlich.Features.Dialogue
IsInDialogue = false;
_isWaitingForAutoClose = false;
if (DialogueBlurEffectURP.Instance != null)
{
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
}
return;
}
@@ -154,7 +154,7 @@ namespace Unheimlich.Features.Dialogue
}
}
public void StartConversation(DialogueConversation conversation, Transform choiceAnchor = null, DialogueAnimationSettings animationSettings = null, DialogueUI customDialogueUI = null, object owner = null)
public void StartConversation(DialogueConversation conversation, Transform choiceAnchor = null, DialogueAnimationSettings animationSettings = null, DialogueUI customDialogueUI = null, object owner = null, bool showBlur = false)
{
if (conversation == null)
{
@@ -175,6 +175,7 @@ namespace Unheimlich.Features.Dialogue
_currentAnimationSettings = animationSettings;
_activeDialogueUI = customDialogueUI;
CurrentOwner = owner;
_blurEnabled = showBlur;
if (_activeDialogueUI == null)
{
@@ -182,6 +183,10 @@ namespace Unheimlich.Features.Dialogue
return;
}
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
PlayerEvents.TriggerEnterDialogue();
ShowCurrentNode();
}
@@ -259,13 +264,14 @@ namespace Unheimlich.Features.Dialogue
public void EndDialogue()
{
IsInDialogue = false;
if (_activeDialogueUI != null)
_activeDialogueUI.HideDialogueBox();
if (DialogueBlurEffectURP.Instance != null)
if (_blurEnabled && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
PlayerEvents.TriggerExitDialogue();
CleanupDialogue();
}
@@ -275,7 +281,8 @@ namespace Unheimlich.Features.Dialogue
_currentAnimationSettings = null;
_activeDialogueUI = null;
CurrentOwner = null;
_blurEnabled = false;
_currentConversation = null;
_currentNode = null;
_autoCloseTimer = 0f;
@@ -60,9 +60,6 @@ namespace Unheimlich.Features.Dialogue
if (_dialogueState.HasPausedDialogue)
{
_dialogueState.ResumeDialogue();
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
else if (!_hasTriggered || (_isRetriggerable && _dialogueState.IsCompleted))
{
@@ -75,12 +72,7 @@ namespace Unheimlich.Features.Dialogue
if (_pauseOnExit && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
{
if ((Object)DialogueManager.Instance.CurrentOwner == this)
{
_dialogueState.PauseDialogue();
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
}
}
}
@@ -100,19 +92,15 @@ namespace Unheimlich.Features.Dialogue
_hasTriggered = true;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
{
DialogueBlurEffectURP.Instance.ShowBlur();
}
DialogueManager.Instance.StartConversation(
_conversation,
_floatingWordAnchor,
_animationSettings,
_localDialogueUI,
this
this,
_enableBlurEffect
);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
}
@@ -127,9 +115,6 @@ namespace Unheimlich.Features.Dialogue
_hasTriggered = false;
_dialogueState.AllowRetrigger();
}
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.HideBlur();
}
if (DialogueManager.Instance != null)
@@ -86,9 +86,6 @@ namespace Unheimlich.Features.Dialogue
_dialogueState.ResumeDialogue();
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
else
{
@@ -98,12 +95,16 @@ namespace Unheimlich.Features.Dialogue
return;
}
DialogueManager.Instance.StartConversation(Conversation, _floatingWordAnchor, _animationSettings, _localDialogueUI, this);
DialogueManager.Instance.StartConversation(
Conversation,
_floatingWordAnchor,
_animationSettings,
_localDialogueUI,
this,
_enableBlurEffect
);
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
_isDialogueActive = true;
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
DialogueBlurEffectURP.Instance.ShowBlur();
}
}
@@ -122,7 +123,7 @@ namespace Unheimlich.Features.Dialogue
if (DialogueManager.Instance != null)
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
}
private void OnDestroy()
{
if (DialogueManager.Instance != null)
@@ -1,7 +1,8 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Unheimlich.Core;
using Unheimlich.Core.Audio;
using Unheimlich.Features.OctoArms;
using InputManager = Unheimlich.Core.Input.InputManager;
using System;
using System.Collections;
@@ -73,12 +74,11 @@ public class DiapasonEquipmentManager : MonoBehaviour
{
CheckForSoundEmitters();
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
var fireAction = InputManager.GetFireAction();
if (fireAction != null && fireAction.WasPressedThisFrame())
{
if (CanTap)
{
TapDiapason();
}
}
}
}
@@ -200,6 +200,8 @@ public class DiapasonEquipmentManager : MonoBehaviour
private void OnDiapasonEquipped()
{
PlayerEvents.TriggerDiapasonEquip();
if (_diapasonAnimator != null)
{
_diapasonAnimator.enabled = true;
@@ -216,6 +218,8 @@ public class DiapasonEquipmentManager : MonoBehaviour
private void OnDiapasonUnequipped()
{
PlayerEvents.TriggerDiapasonUnequip();
_isVibrating = false;
_diapasonAnimator = null;
_diapasonParticles = null;
@@ -270,6 +274,8 @@ public class DiapasonEquipmentManager : MonoBehaviour
{
_currentDetectedEmitter.StartListening(transform.position);
}
PlayerEvents.TriggerDiapasonTap();
if (_vibrationCoroutine != null)
StopCoroutine(_vibrationCoroutine);
@@ -42,28 +42,9 @@ namespace Unheimlich.Features.Diapason
_playerAudioSource = gameObject.AddComponent<AudioSource>();
_cameraHeadBob = GetComponentInChildren<CameraHeadBob>();
if (PlayerController.Instance != null)
{
SubscribeToEquipmentEvents();
}
}
void OnDestroy()
{
if (PlayerController.Instance != null && PlayerController.Instance.DiapasonEquipment != null)
{
UnsubscribeFromEquipmentEvents();
}
}
private void SubscribeToEquipmentEvents()
{
}
private void UnsubscribeFromEquipmentEvents()
{
}
void OnDestroy() { }
void Update()
{
@@ -93,7 +74,8 @@ namespace Unheimlich.Features.Diapason
if (_listeningCoroutine != null)
StopCoroutine(_listeningCoroutine);
PlayerEvents.TriggerEnterListening();
_listeningCoroutine = StartCoroutine(ListeningSequence());
}
@@ -181,6 +163,7 @@ namespace Unheimlich.Features.Diapason
}
InputManager.SetContext(InputManager.InputContext.Normal);
PlayerEvents.TriggerExitListening();
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
@@ -1,112 +1,23 @@
using UnityEngine;
using Unheimlich.Core.Audio;
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.
/// Fires a hallucination effect when a specific AudioSource plays, finishes, or reaches a timestamp.
/// Call OnAudioPlay() from whatever drives the AudioSource to start monitoring.
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class HallucinationOnAudio : MonoBehaviour
public class HallucinationOnAudio : AudioTriggerBase
{
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)")]
[Tooltip("Amount of insanity 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()
protected override void Execute()
{
_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);
@@ -120,7 +31,7 @@ namespace Unheimlich.Features.Hallucination
HallucinationManager.Instance.TriggerHallucinationEvent(_hallucinationEvent);
if (_debugMode)
Debug.Log($"[HallucinationOnAudio] Fired on '{_fireCondition}'. Sanity now: {HallucinationManager.Instance.SanityLevel:F2}", this);
Debug.Log($"[HallucinationOnAudio] Fired on '{_fireCondition}'. Sanity: {HallucinationManager.Instance.SanityLevel:F2}", this);
}
}
}
@@ -1,19 +1,16 @@
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.).
/// Standalone hallucination payload component.
/// Call Trigger() from any external script, UnityEvent, interaction, dialogue callback, etc.
/// No other component required alongside it — compose freely.
/// </summary>
[RequireComponent(typeof(GameEventTrigger))]
public class HallucinationTrigger : MonoBehaviour
{
[Header("Sanity")]
[Tooltip("Amount of sanity to add when this trigger fires (0 = no change)")]
[Tooltip("Amount of insanity to add when this trigger fires (0 = no change)")]
[SerializeField, Range(0f, 1f)] private float _insanityAmount = 0f;
[Header("Hallucination Event")]
@@ -23,22 +20,9 @@ namespace Unheimlich.Features.Hallucination
[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.
/// Fires the hallucination effect.
/// Wire this to any UnityEvent, interaction callback, dialogue node event, audio trigger, etc.
/// </summary>
public void Trigger()
{
@@ -53,7 +37,7 @@ namespace Unheimlich.Features.Hallucination
HallucinationManager.Instance.AddInsanity(_insanityAmount);
if (_debugMode)
Debug.Log($"[HallucinationTrigger] Added {_insanityAmount:F2} insanity. New sanity: {HallucinationManager.Instance.SanityLevel:F2}", this);
Debug.Log($"[HallucinationTrigger] Added {_insanityAmount:F2} insanity. Sanity now: {HallucinationManager.Instance.SanityLevel:F2}", this);
}
if (_hallucinationEvent != null)
@@ -4,30 +4,66 @@ 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.
/// Self-contained zone that triggers a hallucination effect when the player enters.
/// Handles the once-flag, optional delay, insanity increase, and a discrete HallucinationEvent
/// — no GameEventTrigger or HallucinationTrigger required alongside it.
/// </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;
[Tooltip("Delay in seconds before the hallucination fires after the player enters")]
[SerializeField] private float _delay = 0f;
[Header("Sanity")]
[Tooltip("Amount of insanity added when the zone fires (0 = no change)")]
[SerializeField, Range(0f, 1f)] private float _insanityAmount = 0f;
[Header("Hallucination Event")]
[Tooltip("Optional discrete hallucination event (ghost word, vision flash, etc.)")]
[SerializeField] private HallucinationEvent _hallucinationEvent;
[Header("Debug")]
[SerializeField] private bool _debugMode;
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();
if (_delay > 0f)
Invoke(nameof(Fire), _delay);
else
Fire();
}
/// <summary>
/// Fires the hallucination effect. Can also be called from external scripts or UnityEvents.
/// </summary>
public void Fire()
{
if (HallucinationManager.Instance == null)
{
Debug.LogError("[HallucinationTriggerZone] HallucinationManager not found in scene!", this);
return;
}
if (_insanityAmount > 0f)
{
HallucinationManager.Instance.AddInsanity(_insanityAmount);
if (_debugMode)
Debug.Log($"[HallucinationTriggerZone] Added {_insanityAmount:F2} insanity on '{gameObject.name}'.", this);
}
if (_hallucinationEvent != null)
{
HallucinationManager.Instance.TriggerHallucinationEvent(_hallucinationEvent);
if (_debugMode)
Debug.Log($"[HallucinationTriggerZone] Fired event: {_hallucinationEvent.Label}", this);
}
}
}
}
@@ -26,5 +26,22 @@ namespace Unheimlich.Features.Player.Data
[Header("Head Bob - General")]
public float resetSpeed = 5f;
[Tooltip("Amplitude of the subtle Z-axis roll added to the bob (degrees).")]
public float bobRollAmplitude = 0.4f;
[Header("Camera Tilt")]
[Tooltip("Maximum Z roll angle when strafing (degrees).")]
public float strafeTiltAngle = 2.5f;
[Tooltip("Speed at which the strafe tilt lerps in and out.")]
public float strafeTiltSpeed = 6f;
[Header("FOV Kick")]
[Tooltip("FOV added on top of the base FOV when sprinting.")]
public float fovKickAmount = 6f;
[Tooltip("Speed at which FOV lerps to kick target and back.")]
public float fovKickSpeed = 8f;
}
}
@@ -1,5 +1,6 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Unheimlich.Core;
using Unheimlich.Features.Player.Data;
using Unheimlich.Features.Dialogue;
using InputManager = Unheimlich.Core.Input.InputManager;
@@ -7,7 +8,7 @@ using InputManager = Unheimlich.Core.Input.InputManager;
namespace Unheimlich.Features.Player
{
/// <summary>
/// Handles camera effects: aim assist and shake.
/// Handles camera effects: aim assist, shake, and FOV kick on sprint.
/// </summary>
public class CameraEffects : MonoBehaviour
{
@@ -22,29 +23,62 @@ namespace Unheimlich.Features.Player
private Vector3 _pendingAimAssistTarget;
private bool _hasAimAssistTarget;
// FOV kick
private float _baseFov = -1f;
private float _currentFovOffset = 0f;
public bool IsAimLocked { get; private set; }
public bool HasActiveLookInput { get; private set; }
public bool IsUsingGamepad { get; private set; }
void Start()
private void Start()
{
if (Settings != null)
_shakeInterval = 1f / Settings.shakeFrequency;
if (PlayerController.Instance?.PlayerCamera != null)
_baseFov = PlayerController.Instance.PlayerCamera.fieldOfView;
}
void Update()
private void Update()
{
DetectInputDevice();
UpdateFovKick();
}
void LateUpdate()
private void LateUpdate()
{
if (_hasAimAssistTarget)
{
ApplyAimAssistInternal(_pendingAimAssistTarget);
}
}
// ── FOV Kick ─────────────────────────────────────────────────────────────
private void UpdateFovKick()
{
if (Settings == null || PlayerController.Instance?.PlayerCamera == null) return;
// Capture base FOV once (handles cases where FOV is changed externally before Start).
if (_baseFov < 0f)
_baseFov = PlayerController.Instance.PlayerCamera.fieldOfView;
float targetOffset = PlayerController.Instance.IsRunning ? Settings.fovKickAmount : 0f;
_currentFovOffset = Mathf.Lerp(_currentFovOffset, targetOffset, Time.deltaTime * Settings.fovKickSpeed);
PlayerController.Instance.PlayerCamera.fieldOfView = _baseFov + _currentFovOffset;
}
/// <summary>
/// Resets the base FOV reference. Call this if the camera FOV is changed intentionally at runtime.
/// </summary>
public void ResetBaseFov()
{
if (PlayerController.Instance?.PlayerCamera != null)
_baseFov = PlayerController.Instance.PlayerCamera.fieldOfView;
}
// ── Input device detection ────────────────────────────────────────────────
private void DetectInputDevice()
{
Vector2 lookInput = InputManager.GetLookInput();
@@ -56,11 +90,15 @@ namespace Unheimlich.Features.Player
IsUsingGamepad = false;
}
// ── Camera Shake ──────────────────────────────────────────────────────────
public void ShakeCamera(float amplitude, float smoothness = 0f)
{
if (PlayerController.Instance == null || PlayerController.Instance.CameraPivot == null || Settings == null)
if (PlayerController.Instance == null || PlayerController.Instance.CameraPivot == null || Settings == null)
return;
PlayerEvents.TriggerCameraShake(amplitude);
_shakeSmoothness = smoothness;
_shakeTimer -= Time.deltaTime;
@@ -78,8 +116,8 @@ namespace Unheimlich.Features.Player
if (_shakeSmoothness > 0f)
{
PlayerController.Instance.CameraPivot.position = Vector3.Lerp(
PlayerController.Instance.CameraPivot.position,
_shakeTarget,
PlayerController.Instance.CameraPivot.position,
_shakeTarget,
Time.deltaTime * _shakeSmoothness);
}
else
@@ -88,6 +126,8 @@ namespace Unheimlich.Features.Player
}
}
// ── Aim Assist ────────────────────────────────────────────────────────────
public void ApplyAimAssist(Vector3 targetCenter)
{
_pendingAimAssistTarget = targetCenter;
@@ -96,9 +136,9 @@ namespace Unheimlich.Features.Player
private void ApplyAimAssistInternal(Vector3 targetCenter)
{
if (PlayerController.Instance == null || PlayerController.Instance.CameraTransform == null || Settings == null)
if (PlayerController.Instance == null || PlayerController.Instance.CameraTransform == null || Settings == null)
return;
if (PlayerController.Instance.Movement == null)
return;
@@ -113,17 +153,17 @@ namespace Unheimlich.Features.Player
Vector3 targetDirection = directionToTarget;
float targetYaw = Mathf.Atan2(targetDirection.x, targetDirection.z) * Mathf.Rad2Deg;
float targetPitch = -Mathf.Asin(targetDirection.y) * Mathf.Rad2Deg;
float currentYaw = PlayerController.Instance.Movement.Yaw;
float currentPitch = PlayerController.Instance.Movement.Pitch;
float newYaw = Mathf.LerpAngle(currentYaw, targetYaw, Settings.aimAssistStrength * Time.deltaTime);
float newPitch = Mathf.Lerp(currentPitch, targetPitch, Settings.aimAssistStrength * Time.deltaTime);
PlayerController.Instance.Movement.Yaw = newYaw;
PlayerController.Instance.Movement.Pitch = newPitch;
}
_hasAimAssistTarget = false;
}
@@ -6,125 +6,194 @@ namespace Unheimlich.Features.Player
{
/// <summary>
/// Simulates camera head bobbing motion during player movement and idle states.
/// Smoothly transitions between idle, walk and run bob parameters.
/// Adds a subtle Z-axis roll to the bob and a strafe tilt applied in LateUpdate.
/// </summary>
public class CameraHeadBob : MonoBehaviour
{
private float _timer = 0f;
private Vector3 _startLocalPos;
private HeadBobMode _overrideMode = HeadBobMode.Normal;
private bool _hasOverride = false;
private Coroutine _resetCoroutine;
void Start()
{
_startLocalPos = transform.localPosition;
}
private float _timer = 0f;
private Vector3 _startLocalPos;
void Update()
{
if (PlayerController.Instance == null || PlayerController.Instance.CharController == null)
return;
// Smoothly interpolated bob parameters to avoid brutal state switches.
private float _currentBobSpeed = 0f;
private float _currentBobAmplitude = 0f;
var settings = PlayerController.Instance.CameraEffects?.Settings;
if (settings == null) return;
// Strafe tilt (Z roll applied in LateUpdate, after OctoPlayer sets camera pitch).
private float _currentTilt = 0f;
HeadBobMode currentMode = _hasOverride ? _overrideMode : HeadBobMode.Normal;
if (currentMode == HeadBobMode.Disabled)
private HeadBobMode _overrideMode = HeadBobMode.Normal;
private bool _hasOverride = false;
private Coroutine _resetCoroutine;
private void Start()
{
ResetPosition(settings.resetSpeed);
return;
_startLocalPos = transform.localPosition;
}
if (currentMode == HeadBobMode.Idle || (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting))
{
ApplyBob(settings.idleBobSpeed, settings.idleBobAmplitude);
return;
}
bool isMoving = PlayerController.Instance.IsMoving;
bool isRunning = PlayerController.Instance.IsRunning;
bool isGrounded = PlayerController.Instance.CharController.isGrounded;
if (isGrounded)
private void Update()
{
if (isMoving)
if (PlayerController.Instance == null || PlayerController.Instance.CharController == null)
return;
var settings = PlayerController.Instance.CameraEffects?.Settings;
if (settings == null) return;
HeadBobMode currentMode = _hasOverride ? _overrideMode : HeadBobMode.Normal;
if (currentMode == HeadBobMode.Disabled)
{
if (isRunning)
ApplyBob(settings.runBobSpeed, settings.runBobAmplitude);
else
ApplyBob(settings.walkBobSpeed, settings.walkBobAmplitude);
SetBobDirect(settings.idleBobSpeed, 0f);
ApplySmoothedBob(settings.bobRollAmplitude);
return;
}
else
ApplyBob(settings.idleBobSpeed, settings.idleBobAmplitude);
bool forceIdle = currentMode == HeadBobMode.Idle
|| (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting);
if (forceIdle)
{
SetBobDirect(settings.idleBobSpeed, settings.idleBobAmplitude);
ApplySmoothedBob(settings.bobRollAmplitude);
return;
}
bool isGrounded = PlayerController.Instance.CharController.isGrounded;
if (!isGrounded)
{
SetBobDirect(settings.idleBobSpeed, 0f);
ApplySmoothedBob(settings.bobRollAmplitude);
return;
}
// Drive bob parameters directly from the actual physics velocity.
// This is naturally in sync with OctoPlayer's inertia — no extra lerp needed.
float horizontalSpeed = new Vector3(
PlayerController.Instance.CharController.velocity.x,
0f,
PlayerController.Instance.CharController.velocity.z).magnitude;
bool isRunning = PlayerController.Instance.IsRunning;
// Normalise: 0 = stopped, 1 = full walk/run speed (m/s converted from km/h ÷ 3.6).
const float WalkSpeedMs = 10f / 3.6f;
const float RunSpeedMs = 20f / 3.6f;
float maxSpeed = isRunning ? RunSpeedMs : WalkSpeedMs;
float t = Mathf.Clamp01(horizontalSpeed / maxSpeed);
float targetSpeed = Mathf.Lerp(settings.idleBobSpeed, isRunning ? settings.runBobSpeed : settings.walkBobSpeed, t);
float targetAmplitude = Mathf.Lerp(settings.idleBobAmplitude, isRunning ? settings.runBobAmplitude : settings.walkBobAmplitude, t);
SetBobDirect(targetSpeed, targetAmplitude);
ApplySmoothedBob(settings.bobRollAmplitude);
}
else
ResetPosition(settings.resetSpeed);
}
public void SetMode(HeadBobMode mode)
{
_overrideMode = mode;
_hasOverride = true;
}
public void ClearOverride()
{
_hasOverride = false;
if (_resetCoroutine != null)
/// <summary>
/// Applies strafe tilt in LateUpdate so it composites correctly over OctoPlayer's pitch rotation.
/// </summary>
private void LateUpdate()
{
StopCoroutine(_resetCoroutine);
if (PlayerController.Instance == null || PlayerController.Instance.CharController == null)
return;
var settings = PlayerController.Instance.CameraEffects?.Settings;
if (settings == null) return;
// Strafe direction in local space using actual character velocity.
Vector3 velocity = PlayerController.Instance.CharController.velocity;
float strafeAmount = Vector3.Dot(velocity, PlayerController.Instance.transform.right);
// Normalise by run speed (km/h → m/s: / 3.6) to get a -1..1 factor.
float runSpeedMs = PlayerController.Instance.Movement != null ? 20f / 3.6f : 5.5f;
float strafeFactor = Mathf.Clamp(strafeAmount / runSpeedMs, -1f, 1f);
float targetTilt = -strafeFactor * settings.strafeTiltAngle;
_currentTilt = Mathf.Lerp(_currentTilt, targetTilt, Time.deltaTime * settings.strafeTiltSpeed);
// Compose with the existing local rotation (OctoPlayer already wrote pitch into it).
Quaternion baseRot = transform.localRotation;
transform.localRotation = baseRot * Quaternion.Euler(0f, 0f, _currentTilt);
}
// ── Public API ────────────────────────────────────────────────────────────
/// <summary>Overrides the head bob mode (Idle, Normal, Disabled).</summary>
public void SetMode(HeadBobMode mode)
{
_overrideMode = mode;
_hasOverride = true;
}
/// <summary>Clears any active mode override and returns to Normal.</summary>
public void ClearOverride()
{
_hasOverride = false;
if (_resetCoroutine != null)
{
StopCoroutine(_resetCoroutine);
_resetCoroutine = null;
}
}
/// <summary>Lerps the camera local position back to origin over <paramref name="duration"/> seconds.</summary>
public void LerpToZero(float duration)
{
if (_resetCoroutine != null)
StopCoroutine(_resetCoroutine);
_resetCoroutine = StartCoroutine(LerpToZeroCoroutine(duration));
}
// ── Private helpers ───────────────────────────────────────────────────────
/// <summary>Sets bob speed and amplitude directly without lerp — velocity already carries the inertia.</summary>
private void SetBobDirect(float speed, float amplitude)
{
_currentBobSpeed = speed;
_currentBobAmplitude = amplitude;
}
/// <summary>
/// Advances the bob timer and writes the resulting local position offset.
/// Includes a subtle Z roll (Lissajous third axis) for organic feel.
/// </summary>
private void ApplySmoothedBob(float rollAmplitude)
{
_timer += Time.deltaTime * _currentBobSpeed;
float x = Mathf.Sin(_timer * 0.5f) * _currentBobAmplitude * 0.6f;
float y = Mathf.Cos(_timer) * _currentBobAmplitude;
// Micro Z roll: half frequency of Y so it drifts independently.
float roll = Mathf.Sin(_timer * 0.5f + Mathf.PI * 0.25f) * rollAmplitude * (_currentBobAmplitude / Mathf.Max(0.001f, 0.03f));
Vector3 newPos = _startLocalPos;
newPos.x += x;
newPos.y += y;
transform.localPosition = newPos;
// Store roll so LateUpdate can composite it with the strafe tilt.
// We write only the bob-driven roll here; LateUpdate adds strafe tilt on top.
Quaternion bobRot = Quaternion.Euler(0f, 0f, roll);
transform.localRotation = bobRot;
}
private System.Collections.IEnumerator LerpToZeroCoroutine(float duration)
{
Vector3 startPos = transform.localPosition;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
transform.localPosition = Vector3.Lerp(startPos, _startLocalPos, t);
yield return null;
}
transform.localPosition = _startLocalPos;
_resetCoroutine = null;
}
}
public void LerpToZero(float duration)
{
if (_resetCoroutine != null)
StopCoroutine(_resetCoroutine);
_resetCoroutine = StartCoroutine(LerpToZeroCoroutine(duration));
}
private System.Collections.IEnumerator LerpToZeroCoroutine(float duration)
{
Vector3 startPos = transform.localPosition;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
transform.localPosition = Vector3.Lerp(startPos, Vector3.zero, t);
yield return null;
}
transform.localPosition = Vector3.zero;
_resetCoroutine = null;
}
private void ApplyBob(float speed, float amplitude)
{
_timer += Time.deltaTime * speed;
float x = Mathf.Sin(_timer * 0.5f) * amplitude;
float y = Mathf.Cos(_timer) * amplitude;
Vector3 newPos = _startLocalPos;
newPos.x += x;
newPos.y += y;
transform.localPosition = newPos;
}
private void ResetPosition(float resetSpeed)
{
transform.localPosition = Vector3.Lerp(transform.localPosition, _startLocalPos, Time.deltaTime * resetSpeed);
if (Vector3.Distance(transform.localPosition, _startLocalPos) < 0.001f)
_timer = 0f;
}
}
}
@@ -36,34 +36,19 @@ namespace Unheimlich.Features.Player
public InputManager Inputs => InputManager.Instance;
public bool IsMoving
{
get
{
// Read directly from action instead of cached value
var moveAction = InputManager.GetMoveAction();
if (moveAction == null) return false;
return moveAction.ReadValue<UnityEngine.Vector2>().magnitude > 0.1f;
}
}
public bool IsRunning
{
get
{
if (Movement == null || OctoCore.OctoCore.Instance == null) return false;
var actions = OctoCore.OctoCore.Instance.Inputs?.Actions;
if (actions != null)
{
foreach (var actionInfo in actions)
{
if (actionInfo.Name == "Sprint" && actionInfo.Reference != null)
return actionInfo.Reference.action.IsPressed();
}
}
return false;
}
}
// Cached sprint InputAction — resolved once in OnAwake to avoid per-frame foreach.
private UnityEngine.InputSystem.InputAction _sprintAction;
/// <summary>
/// True when the character is physically moving (based on CharacterController velocity,
/// not raw input — correctly synced with OctoPlayer's inertia).
/// </summary>
public bool IsMoving => CharController != null && CharController.velocity.magnitude > 0.1f;
/// <summary>
/// True while the sprint input is held and the player is moving.
/// </summary>
public bool IsRunning => _sprintAction != null && _sprintAction.IsPressed() && IsMoving;
protected override void OnAwake()
{
@@ -76,15 +61,34 @@ namespace Unheimlich.Features.Player
DiapasonEquipment = GetComponent<DiapasonEquipmentManager>();
InspectionHandler = GetComponent<PlayerInspectionHandler>();
DetectiveBoardController = GetComponent<DetectiveBoardController>();
PlayerCamera = GetComponentInChildren<Camera>();
ArmsSystem = GetComponentInChildren<OctoIkArmsImproved>();
if (PlayerCamera != null)
{
CameraTransform = PlayerCamera.transform;
CameraPivot = CameraTransform.parent;
}
CacheSprintAction();
}
/// <summary>
/// Resolves the Sprint InputAction once from OctoCore to avoid per-frame foreach iteration.
/// </summary>
private void CacheSprintAction()
{
if (OctoCore.OctoCore.Instance?.Inputs?.Actions == null) return;
foreach (var actionInfo in OctoCore.OctoCore.Instance.Inputs.Actions)
{
if (actionInfo.Name == "Sprint" && actionInfo.Reference != null)
{
_sprintAction = actionInfo.Reference.action;
break;
}
}
}
// Teleport