Add detective board

This commit is contained in:
2026-02-10 03:00:41 +01:00
parent d4a728a150
commit b172762d4f
51 changed files with 1502 additions and 92 deletions
@@ -2,6 +2,16 @@ using UnityEngine;
namespace Unheimlich.Data.CameraSettings
{
/// <summary>
/// Head bob mode for camera pan.
/// </summary>
public enum HeadBobMode
{
Normal,
Idle,
Disabled
}
/// <summary>
/// ScriptableObject configuration for camera pan and player alignment behavior.
/// Defines pan speed, borders, angles, alignment parameters, and lerp smoothness.
@@ -20,6 +30,16 @@ namespace Unheimlich.Data.CameraSettings
[Tooltip("Maximum angle the camera can pan from center")]
public float MaxPanAngle = 15f;
[Header("Recentering")]
[Tooltip("Enable automatic return to center when idle")]
public bool EnableRecentering = true;
[Tooltip("Speed of automatic recentering (higher = faster return to center)")]
public float RecenterSpeed = 2f;
[Tooltip("Delay before recentering starts (in seconds)")]
public float RecenterDelay = 0.1f;
[Header("Player Alignment")]
[Tooltip("Speed of player/camera alignment to target")]
public float AlignmentSpeed = 3f;
@@ -27,6 +47,10 @@ namespace Unheimlich.Data.CameraSettings
[Tooltip("Threshold for alignment completion")]
public float AlignmentThreshold = 0.5f;
[Header("Head Bob Control")]
[Tooltip("Head bob mode: Normal (default behavior), Idle (force subtle breathing), Disabled (no movement)")]
public HeadBobMode HeadBobMode = HeadBobMode.Idle;
[Header("Lerp Smoothing")]
[Tooltip("Use SmoothStep for smoother interpolation (true) or linear lerp (false)")]
public bool UseSmoothStep = true;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 41fba9d7fa6130e42bec85319cf9caf3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,41 @@
using UnityEngine;
namespace Unheimlich.Data.DetectiveBoard
{
/// <summary>
/// Represents a single clue/evidence node on the detective board.
/// Unlocked through various triggers (audio, objects, dialogues, hallucinations, etc.).
/// </summary>
[CreateAssetMenu(fileName = "ClueData", menuName = "Unheimlich/Detective Board/Clue Data")]
public class ClueData : ScriptableObject
{
[Header("Clue Identity")]
[Tooltip("Unique identifier for this clue")]
public string ClueID;
[Tooltip("Display title on the board")]
public string Title;
[Tooltip("Detailed description of the clue")]
[TextArea(3, 6)]
public string Description;
[Header("Visual Representation")]
[Tooltip("Icon or image for the node (post-it/card visual)")]
public Sprite Icon;
[Tooltip("Color tint for the node")]
public Color NodeColor = Color.white;
[Header("Connection Rules")]
[Tooltip("IDs of clues this can connect to")]
public string[] PossibleConnections;
[Header("Meta")]
[Tooltip("Category for organization (e.g., 'Evidence', 'Witness', 'Location')")]
public string Category;
[Tooltip("Initial position on the board (normalized 0-1)")]
public Vector2 InitialPosition = new Vector2(0.5f, 0.5f);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 166ae120db91b064c9b98086267b9783
@@ -0,0 +1,42 @@
using UnityEngine;
namespace Unheimlich.Data.DetectiveBoard
{
/// <summary>
/// Configuration for detective board UI behavior and appearance.
/// Defines node visuals, connection rules, and interaction parameters.
/// </summary>
[CreateAssetMenu(fileName = "DetectiveBoardSettings", menuName = "Unheimlich/Detective Board/Board Settings")]
public class DetectiveBoardSettings : ScriptableObject
{
[Header("Node Visuals")]
[Tooltip("Default sprite for nodes (post-it/card)")]
public Sprite DefaultNodeSprite;
[Tooltip("Scale of node objects on the board")]
public float NodeScale = 1f;
[Tooltip("Spacing between nodes when auto-arranged")]
public float NodeSpacing = 0.5f;
[Header("Connection Visuals")]
[Tooltip("Line width for connections")]
public float ConnectionLineWidth = 0.05f;
[Tooltip("Color for confirmed connections")]
public Color ConfirmedConnectionColor = Color.green;
[Tooltip("Color for uncertain connections")]
public Color UncertainConnectionColor = Color.yellow;
[Tooltip("Color for eliminated connections")]
public Color EliminatedConnectionColor = Color.red;
[Header("Interaction")]
[Tooltip("Distance at which nodes can be interacted with")]
public float InteractionRange = 2f;
[Tooltip("Highlight color when hovering over a node")]
public Color HoverHighlightColor = new Color(1f, 1f, 0.5f, 1f);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6b20768fe570fb429fcf6a348431232
@@ -0,0 +1,38 @@
using UnityEngine;
using UnityEditor;
using Unheimlich.Data.DetectiveBoard;
namespace Unheimlich.Editor
{
[CustomEditor(typeof(ClueData))]
public class ClueDataEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
ClueData clueData = (ClueData)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Quick Actions", EditorStyles.boldLabel);
if (GUILayout.Button("Generate Unique ID"))
{
clueData.ClueID = System.Guid.NewGuid().ToString();
EditorUtility.SetDirty(clueData);
}
if (GUILayout.Button("Test Unlock Clue (Play Mode Only)"))
{
if (Application.isPlaying)
{
Managers.DetectiveBoardManager.Instance?.UnlockClue(clueData);
}
else
{
Debug.LogWarning("This button only works in Play Mode!");
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 184cbe5b79ee0a84ba29da016ec76159
@@ -2,6 +2,7 @@ using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
using Unheimlich.Data.CameraSettings;
using PlayerInput = Unheimlich.Managers.PlayerInputManager;
namespace Unheimlich.Gameplay.CameraControl
{
@@ -18,9 +19,6 @@ namespace Unheimlich.Gameplay.CameraControl
[Header("Settings")]
[SerializeField] private CameraPanSettings _settings;
[Header("Input Actions")]
[SerializeField] private InputActionReference _lookAction;
private Vector2 _originalCameraRotation;
private Vector3 _originalCameraLocalPosition;
private Vector3 _originalPlayerPosition;
@@ -29,7 +27,6 @@ namespace Unheimlich.Gameplay.CameraControl
private Coroutine _alignmentCoroutine;
private Coroutine _returnCoroutine;
private float _timeSinceLastInput;
private const float INPUT_IDLE_THRESHOLD = 0.1f;
public bool IsPanning { get; private set; }
@@ -99,9 +96,10 @@ namespace Unheimlich.Gameplay.CameraControl
float panY = 0f;
bool hasInput = false;
if (_lookAction != null && _lookAction.action != null)
var lookAction = PlayerInput.GetLookAction();
if (lookAction != null)
{
Vector2 lookInput = _lookAction.action.ReadValue<Vector2>();
Vector2 lookInput = lookAction.ReadValue<Vector2>();
if (lookInput.sqrMagnitude > 0.01f)
{
panX = lookInput.x;
@@ -150,29 +148,37 @@ namespace Unheimlich.Gameplay.CameraControl
_timeSinceLastInput += Time.deltaTime;
}
float targetYaw;
float targetPitch;
if (hasInput || _timeSinceLastInput < INPUT_IDLE_THRESHOLD)
{
targetYaw = _originalCameraRotation.x + panX * _settings.MaxPanAngle;
targetPitch = _originalCameraRotation.y - panY * _settings.MaxPanAngle;
}
else
{
targetYaw = _originalCameraRotation.x;
targetPitch = _originalCameraRotation.y;
}
targetYaw = Mathf.Clamp(targetYaw, _originalCameraRotation.x - _settings.MaxPanAngle, _originalCameraRotation.x + _settings.MaxPanAngle);
targetPitch = Mathf.Clamp(targetPitch, _originalCameraRotation.y - _settings.MaxPanAngle, _originalCameraRotation.y + _settings.MaxPanAngle);
float currentYaw = _playerTransform.eulerAngles.y;
float currentPitch = _camera.transform.localEulerAngles.x;
if (currentPitch > 180f)
currentPitch -= 360f;
float lerpSpeed = hasInput ? _settings.PanSpeed : _settings.PanSpeed * 2f;
float targetYaw;
float targetPitch;
float lerpSpeed;
bool shouldRecenter = _settings.EnableRecentering && _timeSinceLastInput >= _settings.RecenterDelay;
if (hasInput)
{
targetYaw = _originalCameraRotation.x + panX * _settings.MaxPanAngle;
targetPitch = _originalCameraRotation.y - panY * _settings.MaxPanAngle;
targetYaw = Mathf.Clamp(targetYaw, _originalCameraRotation.x - _settings.MaxPanAngle, _originalCameraRotation.x + _settings.MaxPanAngle);
targetPitch = Mathf.Clamp(targetPitch, _originalCameraRotation.y - _settings.MaxPanAngle, _originalCameraRotation.y + _settings.MaxPanAngle);
lerpSpeed = _settings.PanSpeed;
}
else if (shouldRecenter)
{
targetYaw = _originalCameraRotation.x;
targetPitch = _originalCameraRotation.y;
lerpSpeed = _settings.RecenterSpeed;
}
else
{
targetYaw = currentYaw;
targetPitch = currentPitch;
lerpSpeed = 0f;
}
float newYaw = Mathf.LerpAngle(currentYaw, targetYaw, Time.deltaTime * lerpSpeed);
float newPitch = Mathf.LerpAngle(currentPitch, targetPitch, Time.deltaTime * lerpSpeed);
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 82ca56838506a694196a2130ecff8694
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
using UnityEngine;
using Unheimlich.Data.CameraSettings;
using Unheimlich.Gameplay.CameraControl;
using Unheimlich.Gameplay.Player;
using PlayerInput = Unheimlich.Managers.PlayerInputManager;
namespace Unheimlich.Gameplay.DetectiveBoard
{
/// <summary>
/// Controls player interaction with the detective board in world space.
/// Uses CameraPanController for wide-angle camera movement across the board.
/// Activates when player is in range and looking at the board.
/// </summary>
public class DetectiveBoardController : MonoBehaviour
{
[Header("References")]
[SerializeField] private CameraPanController _cameraPanController;
[SerializeField] private Transform _boardTransform;
[Header("Settings")]
[SerializeField] private CameraPanSettings _boardCameraPanSettings;
[SerializeField] private float _activationRange = 2f;
[SerializeField] private LayerMask _boardLayer;
private Camera _playerCamera;
private bool _isBoardActive;
private CameraPanSettings _originalCameraPanSettings;
private CameraHeadBob _cameraHeadBob;
private void Awake()
{
_playerCamera = Camera.main;
if (_cameraPanController == null)
{
_cameraPanController = FindFirstObjectByType<CameraPanController>();
}
_cameraHeadBob = FindFirstObjectByType<CameraHeadBob>();
}
private void Update()
{
if (_isBoardActive)
{
HandleBoardInteraction();
}
else
{
CheckForBoardActivation();
}
}
/// <summary>
/// Checks if player is looking at the board and in range to activate.
/// </summary>
private void CheckForBoardActivation()
{
if (_playerCamera == null || _boardTransform == null)
return;
float distanceToBoard = Vector3.Distance(_playerCamera.transform.position, _boardTransform.position);
if (distanceToBoard > _activationRange)
return;
Ray ray = new Ray(_playerCamera.transform.position, _playerCamera.transform.forward);
if (Physics.Raycast(ray, out RaycastHit hit, _activationRange, _boardLayer))
{
if (hit.transform.IsChildOf(_boardTransform) || hit.transform == _boardTransform)
{
var interactAction = PlayerInput.GetInteractAction();
if (interactAction != null && interactAction.WasPressedThisFrame())
{
ActivateBoard();
}
}
}
}
/// <summary>
/// Activates the detective board, switching to pan mode with wide-angle settings.
/// </summary>
public void ActivateBoard()
{
if (_cameraPanController == null || _boardCameraPanSettings == null)
{
Debug.LogError("[DetectiveBoardController] Missing required references!");
return;
}
_isBoardActive = true;
_originalCameraPanSettings = _cameraPanController.Settings;
_cameraPanController.Settings = _boardCameraPanSettings;
_cameraPanController.SaveOriginalState();
_cameraPanController.AlignToTarget(_boardTransform, () =>
{
_cameraPanController.StartPan();
});
PlayerInput.SetContext(PlayerInput.InputContext.Listening);
ApplyHeadBobSettings(_boardCameraPanSettings);
Debug.Log("[DetectiveBoardController] Detective board activated");
}
/// <summary>
/// Handles board interaction while active (panning and exit).
/// </summary>
private void HandleBoardInteraction()
{
if (_cameraPanController != null)
{
_cameraPanController.UpdatePan();
}
var exitAction = PlayerInput.GetExitAction();
if (exitAction != null && exitAction.WasPressedThisFrame())
{
DeactivateBoard();
}
}
/// <summary>
/// Deactivates the board and returns to normal gameplay.
/// </summary>
public void DeactivateBoard()
{
if (!_isBoardActive)
return;
_isBoardActive = false;
if (_cameraPanController != null)
{
_cameraPanController.StopPan();
_cameraPanController.ReturnToOriginalPosition(() =>
{
if (_originalCameraPanSettings != null)
{
_cameraPanController.Settings = _originalCameraPanSettings;
}
});
}
PlayerInput.SetContext(PlayerInput.InputContext.Normal);
RestoreHeadBob();
Debug.Log("[DetectiveBoardController] Detective board deactivated");
}
/// <summary>
/// Applies head bob settings based on CameraPanSettings configuration.
/// </summary>
private void ApplyHeadBobSettings(CameraPanSettings settings)
{
if (_cameraHeadBob == null || settings == null)
return;
_cameraHeadBob.SetMode(settings.HeadBobMode);
}
/// <summary>
/// Restores head bob to normal behavior.
/// </summary>
private void RestoreHeadBob()
{
if (_cameraHeadBob == null)
return;
_cameraHeadBob.ClearOverride();
}
private void OnDrawGizmosSelected()
{
if (_boardTransform == null)
return;
Gizmos.color = _isBoardActive ? Color.green : Color.yellow;
Gizmos.DrawWireSphere(_boardTransform.position, _activationRange);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f9f72364623a2a4bb50f02537fd2e72
@@ -1,5 +1,6 @@
using UnityEngine;
using Unheimlich.Interactions.Core;
using Unheimlich.Data.CameraSettings;
namespace Unheimlich.Gameplay.Player
{
@@ -21,6 +22,9 @@ namespace Unheimlich.Gameplay.Player
private PlayerMovement _playerMovement;
private float _timer = 0f;
private Vector3 _startLocalPos;
private HeadBobMode _overrideMode = HeadBobMode.Normal;
private bool _hasOverride = false;
void Start()
{
@@ -33,7 +37,15 @@ namespace Unheimlich.Gameplay.Player
if (_playerMovement == null || _playerMovement.CharacterController == null)
return;
if (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting)
HeadBobMode currentMode = _hasOverride ? _overrideMode : HeadBobMode.Normal;
if (currentMode == HeadBobMode.Disabled)
{
ResetPosition();
return;
}
if (currentMode == HeadBobMode.Idle || (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting))
{
ApplyBob(_idleBobSpeed, _idleBobAmplitude);
return;
@@ -52,6 +64,23 @@ namespace Unheimlich.Gameplay.Player
else
ResetPosition();
}
/// <summary>
/// Sets the head bob mode override.
/// </summary>
public void SetMode(HeadBobMode mode)
{
_overrideMode = mode;
_hasOverride = true;
}
/// <summary>
/// Clears the override and returns to normal behavior.
/// </summary>
public void ClearOverride()
{
_hasOverride = false;
}
private void ApplyBob(float speed, float amplitude)
{
@@ -60,10 +60,6 @@ namespace Unheimlich.Gameplay.Player
[Header("Touch Settings")]
[SerializeField] private float _touchDistance = 2f;
[Header("Input Action")]
[SerializeField] private InputActionReference _diapasonAction;
[SerializeField] private InputActionReference _fireAction;
public bool IsListening { get; private set; }
public bool IsConcentrating { get; private set; }
@@ -118,11 +114,6 @@ namespace Unheimlich.Gameplay.Player
{
_handTouchFeedback.SetActive(false);
}
if (_diapasonAction == null)
{
Debug.LogError("[Diapason] _diapasonAction is NOT assigned! Please assign it in the Inspector.");
}
}
private void Update()
@@ -132,7 +123,8 @@ namespace Unheimlich.Gameplay.Player
CheckForNearbyEmitter();
}
if (_diapasonAction != null && _diapasonAction.action.WasPressedThisFrame())
var diapasonAction = InputManager.GetDiapasonAction();
if (diapasonAction != null && diapasonAction.WasPressedThisFrame())
{
if (!IsListening && !IsConcentrating && _canUseDiapason)
{
@@ -419,7 +411,8 @@ namespace Unheimlich.Gameplay.Player
}
bool firePressed = false;
if (_fireAction != null && _fireAction.action != null && _fireAction.action.WasPressedThisFrame())
var fireAction = InputManager.GetFireAction();
if (fireAction != null && fireAction.WasPressedThisFrame())
{
firePressed = true;
}
@@ -49,7 +49,8 @@ namespace Unheimlich.Gameplay.Player
{
_ySpeed = _groundedGravity;
if (PlayerInputManager.Instance.JumpAction.action.WasPressedThisFrame())
var jumpAction = PlayerInputManager.GetJumpAction();
if (jumpAction != null && jumpAction.WasPressedThisFrame())
_ySpeed = _jumpForce;
}
else
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e555ea44c35017a42be4dd7fc559e05b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,115 @@
using UnityEngine;
using Unheimlich.Data.DetectiveBoard;
using Unheimlich.Managers;
namespace Unheimlich.Handlers.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.
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class ClueUnlockOnAudio : MonoBehaviour
{
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()
{
_audioSource = GetComponent<AudioSource>();
}
private void OnEnable()
{
if (_audioSource != null && _audioSource.isPlaying)
{
StartMonitoring();
}
}
private void Update()
{
if (!_isMonitoring || _hasUnlocked)
return;
if (_audioSource == null || !_audioSource.isPlaying)
{
if (_trigger == UnlockTrigger.OnFinish && _isMonitoring)
{
UnlockClue();
}
_isMonitoring = false;
return;
}
_currentTime = _audioSource.time;
if (_trigger == UnlockTrigger.OnTimeReached && _currentTime >= _unlockTime)
{
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)
return;
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
_hasUnlocked = true;
Debug.Log($"[ClueUnlockOnAudio] Unlocked clue: {_clueToUnlock.Title} via audio trigger");
}
}
/// <summary>
/// Resets the unlock state.
/// </summary>
public void ResetUnlock()
{
_hasUnlocked = false;
_isMonitoring = false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fcedba870c1ea9642a272a53148bb933
@@ -0,0 +1,43 @@
using UnityEngine;
using Unheimlich.Data.DetectiveBoard;
using Unheimlich.Interactions.Core;
using Unheimlich.Managers;
namespace Unheimlich.Handlers.DetectiveBoard
{
/// <summary>
/// Unlocks a clue when the player interacts with this object.
/// Extends InteractableBase to integrate with the existing interaction system.
/// </summary>
public class ClueUnlockOnInteraction : InteractableBase
{
[Header("Clue Configuration")]
[Tooltip("The clue to unlock when player interacts")]
[SerializeField] private ClueData _clueToUnlock;
[Tooltip("Only unlock once")]
[SerializeField] private bool _unlockOnlyOnce = true;
private bool _hasUnlocked;
public override void Interact(GameObject interactor)
{
if (_unlockOnlyOnce && _hasUnlocked)
return;
if (_clueToUnlock == null)
{
Debug.LogError("[ClueUnlockOnInteraction] No clue data assigned!");
return;
}
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
_hasUnlocked = true;
Debug.Log($"[ClueUnlockOnInteraction] Unlocked clue: {_clueToUnlock.Title} via interaction");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cca584f48d1f0ab429416470fd01d543
@@ -0,0 +1,105 @@
using UnityEngine;
using Unheimlich.Data.DetectiveBoard;
using Unheimlich.Managers;
namespace Unheimlich.Handlers.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.
/// </summary>
public class ClueUnlockTrigger : MonoBehaviour
{
[Header("Clue Configuration")]
[Tooltip("The clue data to unlock when triggered")]
[SerializeField] private ClueData _clueToUnlock;
[Header("Trigger Settings")]
[Tooltip("Automatically unlock on Start()")]
[SerializeField] private bool _unlockOnStart;
[Tooltip("Unlock when this GameObject is enabled")]
[SerializeField] private bool _unlockOnEnable;
[Tooltip("Delay before unlocking (useful for sequencing)")]
[SerializeField] private float _unlockDelay;
[Tooltip("Show debug messages")]
[SerializeField] private bool _debugMode;
private bool _hasUnlocked;
private void Start()
{
if (_unlockOnStart)
{
UnlockClue();
}
}
private void OnEnable()
{
if (_unlockOnEnable && !_hasUnlocked)
{
UnlockClue();
}
}
/// <summary>
/// Unlocks the associated clue. Can be called from external scripts, events, or animations.
/// </summary>
public void UnlockClue()
{
if (_hasUnlocked)
{
if (_debugMode)
Debug.LogWarning($"[ClueUnlockTrigger] Clue '{_clueToUnlock?.Title}' already unlocked!");
return;
}
if (_clueToUnlock == null)
{
Debug.LogError("[ClueUnlockTrigger] No clue data assigned!");
return;
}
if (_unlockDelay > 0f)
{
Invoke(nameof(UnlockClueImmediate), _unlockDelay);
}
else
{
UnlockClueImmediate();
}
}
private void UnlockClueImmediate()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
_hasUnlocked = true;
if (_debugMode)
Debug.Log($"[ClueUnlockTrigger] Unlocked clue: {_clueToUnlock.Title}");
OnClueUnlocked?.Invoke();
}
else
{
Debug.LogError("[ClueUnlockTrigger] DetectiveBoardManager not found!");
}
}
/// <summary>
/// Resets the unlock state (useful for testing).
/// </summary>
public void ResetUnlock()
{
_hasUnlocked = false;
}
public event System.Action OnClueUnlocked;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61b90106a531ea840b670f368e2c8aa9
@@ -44,8 +44,8 @@ namespace Unheimlich.Interactions.Core
private void HandleInteractionInput()
{
if (PlayerInputManager.Instance != null &&
PlayerInputManager.Instance.InteractAction.action.WasPressedThisFrame())
var interactAction = PlayerInputManager.GetInteractAction();
if (interactAction != null && interactAction.WasPressedThisFrame())
{
if (_currentInteractable != null)
_currentInteractable.Interact(transform.parent.gameObject);
@@ -87,7 +87,8 @@ namespace Unheimlich.Interactions.Core
while (IsInspecting)
{
if (InputManager.Instance.InteractAction.action.WasPressedThisFrame())
var interactAction = InputManager.GetInteractAction();
if (interactAction != null && interactAction.WasPressedThisFrame())
{
StopInspectionInternal();
yield break;
@@ -0,0 +1,193 @@
using System.Collections.Generic;
using UnityEngine;
using Unheimlich.Core;
using Unheimlich.Data.DetectiveBoard;
namespace Unheimlich.Managers
{
/// <summary>
/// Manages the detective board state and clue unlocking system.
/// Tracks unlocked clues, connections, certainty levels, and eliminated hypotheses.
/// Does NOT affect game progression - purely interpretive tool for the player.
/// </summary>
public class DetectiveBoardManager : Singleton<DetectiveBoardManager>
{
public enum CertaintyLevel
{
Uncertain,
Possible,
Confident,
Certain
}
public class ClueState
{
public ClueData Data;
public bool IsUnlocked;
public bool IsEliminated;
public Vector2 BoardPosition;
}
public class Connection
{
public string FromClueID;
public string ToClueID;
public CertaintyLevel Certainty;
public bool IsEliminated;
}
[Header("References")]
[SerializeField] private DetectiveBoardSettings _settings;
private Dictionary<string, ClueState> _clueStates = new Dictionary<string, ClueState>();
private List<Connection> _connections = new List<Connection>();
public DetectiveBoardSettings Settings => _settings;
public IReadOnlyDictionary<string, ClueState> ClueStates => _clueStates;
public IReadOnlyList<Connection> Connections => _connections;
/// <summary>
/// Unlocks a clue on the detective board. Can be triggered by any in-game event.
/// </summary>
public void UnlockClue(ClueData clueData)
{
if (clueData == null)
{
Debug.LogError("[DetectiveBoardManager] Attempted to unlock null clue!");
return;
}
if (!_clueStates.ContainsKey(clueData.ClueID))
{
_clueStates[clueData.ClueID] = new ClueState
{
Data = clueData,
IsUnlocked = true,
IsEliminated = false,
BoardPosition = clueData.InitialPosition
};
Debug.Log($"[DetectiveBoardManager] Unlocked clue: {clueData.Title}");
OnClueUnlocked?.Invoke(clueData);
}
else if (!_clueStates[clueData.ClueID].IsUnlocked)
{
_clueStates[clueData.ClueID].IsUnlocked = true;
Debug.Log($"[DetectiveBoardManager] Unlocked clue: {clueData.Title}");
OnClueUnlocked?.Invoke(clueData);
}
}
/// <summary>
/// Unlocks a clue by its ID string.
/// </summary>
public void UnlockClueByID(string clueID)
{
if (_clueStates.TryGetValue(clueID, out ClueState state))
{
if (!state.IsUnlocked)
{
state.IsUnlocked = true;
Debug.Log($"[DetectiveBoardManager] Unlocked clue: {state.Data.Title}");
OnClueUnlocked?.Invoke(state.Data);
}
}
else
{
Debug.LogWarning($"[DetectiveBoardManager] Clue ID '{clueID}' not found!");
}
}
/// <summary>
/// Registers a clue in the system without unlocking it.
/// </summary>
public void RegisterClue(ClueData clueData)
{
if (clueData == null || _clueStates.ContainsKey(clueData.ClueID))
return;
_clueStates[clueData.ClueID] = new ClueState
{
Data = clueData,
IsUnlocked = false,
IsEliminated = false,
BoardPosition = clueData.InitialPosition
};
}
/// <summary>
/// Creates a connection between two clues.
/// </summary>
public void CreateConnection(string fromClueID, string toClueID, CertaintyLevel certainty = CertaintyLevel.Uncertain)
{
if (!_clueStates.ContainsKey(fromClueID) || !_clueStates.ContainsKey(toClueID))
{
Debug.LogWarning($"[DetectiveBoardManager] Cannot create connection - one or both clues not found!");
return;
}
Connection existing = _connections.Find(c =>
(c.FromClueID == fromClueID && c.ToClueID == toClueID) ||
(c.FromClueID == toClueID && c.ToClueID == fromClueID));
if (existing != null)
{
existing.Certainty = certainty;
}
else
{
_connections.Add(new Connection
{
FromClueID = fromClueID,
ToClueID = toClueID,
Certainty = certainty,
IsEliminated = false
});
OnConnectionCreated?.Invoke(fromClueID, toClueID);
}
}
/// <summary>
/// Marks a clue as eliminated (incorrect hypothesis).
/// </summary>
public void EliminateClue(string clueID)
{
if (_clueStates.TryGetValue(clueID, out ClueState state))
{
state.IsEliminated = true;
OnClueEliminated?.Invoke(clueID);
}
}
/// <summary>
/// Marks a connection as eliminated.
/// </summary>
public void EliminateConnection(string fromClueID, string toClueID)
{
Connection connection = _connections.Find(c =>
(c.FromClueID == fromClueID && c.ToClueID == toClueID) ||
(c.FromClueID == toClueID && c.ToClueID == fromClueID));
if (connection != null)
{
connection.IsEliminated = true;
}
}
/// <summary>
/// Updates a clue's position on the board.
/// </summary>
public void UpdateCluePosition(string clueID, Vector2 newPosition)
{
if (_clueStates.TryGetValue(clueID, out ClueState state))
{
state.BoardPosition = newPosition;
}
}
public event System.Action<ClueData> OnClueUnlocked;
public event System.Action<string, string> OnConnectionCreated;
public event System.Action<string> OnClueEliminated;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b187cdccaa498441a752e8f49ff4290
@@ -107,8 +107,8 @@ namespace Unheimlich.Managers
}
}
if (PlayerInputManager.Instance != null &&
PlayerInputManager.Instance.InteractAction.action.WasPressedThisFrame())
var interactAction = PlayerInputManager.GetInteractAction();
if (interactAction != null && interactAction.WasPressedThisFrame())
{
EndDialogue();
}
@@ -26,18 +26,19 @@ namespace Unheimlich.Managers
[Header("Input Values (Read only)")]
public Vector2 MoveInput;
public Vector2 LookInput;
public InputActionReference JumpAction;
public InputActionReference InteractAction;
public InputActionReference DiapasonAction;
public static bool IsInputEnabled { get; private set; } = true;
public static bool IsLookingEnabled { get; private set; } = true;
public static InputContext CurrentContext { get; private set; } = InputContext.Normal;
private InputActionMap _playerActionMap;
private InputAction _moveAction;
private InputAction _lookAction;
private InputAction _jumpAction;
private InputAction _interactAction;
private InputAction _diapasonAction;
private InputAction _fireAction;
private InputAction _exitAction;
private PlayerMovement _playerMovement;
private PlayerLook _playerLook;
@@ -51,14 +52,13 @@ namespace Unheimlich.Managers
playerInput.actions.Enable();
_playerActionMap = playerInput.actions.FindActionMap("Player");
_moveAction = _playerActionMap.FindAction("Move");
_lookAction = _playerActionMap.FindAction("Look");
_jumpAction = _playerActionMap.FindAction("Jump");
_interactAction = _playerActionMap.FindAction("Interact");
_diapasonAction = _playerActionMap.FindAction("Diapason");
JumpAction.action.Enable();
InteractAction.action.Enable();
if (DiapasonAction != null)
DiapasonAction.action.Enable();
_fireAction = _playerActionMap.FindAction("Fire");
_exitAction = _playerActionMap.FindAction("Exit");
_playerMovement = GetComponent<PlayerMovement>();
_playerLook = GetComponent<PlayerLook>();
@@ -101,7 +101,11 @@ namespace Unheimlich.Managers
Instance._lookAction.Enable();
if (Instance._diapasonAction != null)
Instance._diapasonAction.Enable();
Instance.SetComponentsEnabled(false, false, false);
if (Instance._fireAction != null)
Instance._fireAction.Enable();
if (Instance._exitAction != null)
Instance._exitAction.Enable();
Instance.SetComponentsEnabled(false, false, true);
IsInputEnabled = false;
IsLookingEnabled = false;
break;
@@ -152,5 +156,24 @@ namespace Unheimlich.Managers
public void OnDiapason(InputAction.CallbackContext context)
{
}
public void OnFire(InputAction.CallbackContext context)
{
}
public void OnExit(InputAction.CallbackContext context)
{
}
/// <summary>
/// Provides public access to input actions for other systems.
/// </summary>
public static InputAction GetMoveAction() => Instance?._moveAction;
public static InputAction GetLookAction() => Instance?._lookAction;
public static InputAction GetJumpAction() => Instance?._jumpAction;
public static InputAction GetInteractAction() => Instance?._interactAction;
public static InputAction GetDiapasonAction() => Instance?._diapasonAction;
public static InputAction GetFireAction() => Instance?._fireAction;
public static InputAction GetExitAction() => Instance?._exitAction;
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2516dfad26afafc43b9ec93eda9f2155
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,93 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unheimlich.Data.DetectiveBoard;
namespace Unheimlich.UI.DetectiveBoard
{
/// <summary>
/// Visual representation of a clue node on the detective board.
/// Displays as a post-it/card with title, description, and icon.
/// Supports visual states: unlocked, eliminated, highlighted.
/// </summary>
public class ClueNodeUI : MonoBehaviour
{
[Header("References")]
[SerializeField] private Image _nodeImage;
[SerializeField] private TextMeshProUGUI _titleText;
[SerializeField] private Image _iconImage;
[SerializeField] private GameObject _eliminatedOverlay;
private ClueData _clueData;
private bool _isEliminated;
private Color _originalColor;
public ClueData ClueData => _clueData;
public bool IsEliminated => _isEliminated;
/// <summary>
/// Initializes the node with clue data.
/// </summary>
public void Initialize(ClueData clueData)
{
_clueData = clueData;
if (_titleText != null)
_titleText.text = clueData.Title;
if (_iconImage != null && clueData.Icon != null)
{
_iconImage.sprite = clueData.Icon;
_iconImage.enabled = true;
}
else if (_iconImage != null)
{
_iconImage.enabled = false;
}
if (_nodeImage != null)
{
_originalColor = clueData.NodeColor;
_nodeImage.color = _originalColor;
}
if (_eliminatedOverlay != null)
_eliminatedOverlay.SetActive(false);
}
/// <summary>
/// Marks this node as eliminated (incorrect hypothesis).
/// </summary>
public void SetEliminated(bool eliminated)
{
_isEliminated = eliminated;
if (_eliminatedOverlay != null)
_eliminatedOverlay.SetActive(eliminated);
if (_nodeImage != null)
{
_nodeImage.color = eliminated ? Color.gray : _originalColor;
}
}
/// <summary>
/// Highlights the node when hovered.
/// </summary>
public void SetHighlighted(bool highlighted, Color highlightColor)
{
if (_nodeImage != null && !_isEliminated)
{
_nodeImage.color = highlighted ? highlightColor : _originalColor;
}
}
/// <summary>
/// Updates the position of this node on the board (world space or canvas).
/// </summary>
public void SetPosition(Vector3 position)
{
transform.position = position;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a7086ec4620627b458b08500e412ee90
@@ -0,0 +1,90 @@
using UnityEngine;
using Unheimlich.Managers;
namespace Unheimlich.UI.DetectiveBoard
{
/// <summary>
/// Renders a connection line between two clue nodes on the detective board.
/// Visual style changes based on certainty level and elimination state.
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class ConnectionLineRenderer : MonoBehaviour
{
private LineRenderer _lineRenderer;
private string _fromClueID;
private string _toClueID;
private DetectiveBoardManager.CertaintyLevel _certainty;
private bool _isEliminated;
public string FromClueID => _fromClueID;
public string ToClueID => _toClueID;
private void Awake()
{
_lineRenderer = GetComponent<LineRenderer>();
}
/// <summary>
/// Initializes the connection line between two nodes.
/// </summary>
public void Initialize(string fromClueID, string toClueID,
DetectiveBoardManager.CertaintyLevel certainty, Color color, float width)
{
_fromClueID = fromClueID;
_toClueID = toClueID;
_certainty = certainty;
if (_lineRenderer != null)
{
_lineRenderer.startWidth = width;
_lineRenderer.endWidth = width;
_lineRenderer.material = new Material(Shader.Find("Sprites/Default"));
_lineRenderer.startColor = color;
_lineRenderer.endColor = color;
}
}
/// <summary>
/// Updates the line positions based on node transforms.
/// </summary>
public void UpdateLine(Vector3 startPos, Vector3 endPos)
{
if (_lineRenderer != null)
{
_lineRenderer.SetPosition(0, startPos);
_lineRenderer.SetPosition(1, endPos);
}
}
/// <summary>
/// Updates the certainty level and visual appearance.
/// </summary>
public void SetCertainty(DetectiveBoardManager.CertaintyLevel certainty, Color color)
{
_certainty = certainty;
if (_lineRenderer != null)
{
_lineRenderer.startColor = color;
_lineRenderer.endColor = color;
}
}
/// <summary>
/// Marks this connection as eliminated.
/// </summary>
public void SetEliminated(bool eliminated)
{
_isEliminated = eliminated;
if (_lineRenderer != null)
{
float alpha = eliminated ? 0.3f : 1f;
Color color = _lineRenderer.startColor;
color.a = alpha;
_lineRenderer.startColor = color;
_lineRenderer.endColor = color;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d829d9811842b241b980bd64ab73bff