Complete reorga
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e30b918248fbaa45bd741cc5a8bbeac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 41ce6b2bff04d1e4d9eb45ac2c7dd0d9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.Data
|
||||
{
|
||||
[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("Meta")]
|
||||
[Tooltip("Category for organization (e.g., 'Evidence', 'Witness', 'Location')")]
|
||||
public string Category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 166ae120db91b064c9b98086267b9783
|
||||
@@ -0,0 +1,75 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Features.DetectiveBoard.UI;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.Data
|
||||
{
|
||||
[Serializable]
|
||||
public class PostItNodeData
|
||||
{
|
||||
public string NodeID;
|
||||
public Vector2 Position;
|
||||
public float Rotation;
|
||||
public bool IsEliminated;
|
||||
public List<ConnectionData> Connections = new List<ConnectionData>();
|
||||
|
||||
public PostItNodeData(string id, Vector2 position)
|
||||
{
|
||||
NodeID = id;
|
||||
Position = position;
|
||||
Rotation = 0f;
|
||||
IsEliminated = false;
|
||||
}
|
||||
|
||||
public List<string> ConnectedNodeIDs
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> ids = new List<string>();
|
||||
foreach (var conn in Connections)
|
||||
{
|
||||
ids.Add(conn.TargetNodeID);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConnectionData
|
||||
{
|
||||
public string TargetNodeID;
|
||||
public LineColorState ColorState;
|
||||
|
||||
public ConnectionData(string targetId, LineColorState colorState = LineColorState.Green)
|
||||
{
|
||||
TargetNodeID = targetId;
|
||||
ColorState = colorState;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DetectiveBoardSaveData
|
||||
{
|
||||
public List<PostItNodeData> Nodes = new List<PostItNodeData>();
|
||||
|
||||
public PostItNodeData GetNodeData(string nodeID)
|
||||
{
|
||||
return Nodes.Find(n => n.NodeID == nodeID);
|
||||
}
|
||||
|
||||
public void AddOrUpdateNode(PostItNodeData nodeData)
|
||||
{
|
||||
int index = Nodes.FindIndex(n => n.NodeID == nodeData.NodeID);
|
||||
if (index >= 0)
|
||||
{
|
||||
Nodes[index] = nodeData;
|
||||
}
|
||||
else
|
||||
{
|
||||
Nodes.Add(nodeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2344709e137fdf46aaa5445a330db2f
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7317abe29dcd654f99d8d2ad8e1d179
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
using Unheimlich.Features.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)
|
||||
{
|
||||
DetectiveBoardManager.Instance?.UnlockClue(clueData);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("This button only works in Play Mode!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 184cbe5b79ee0a84ba29da016ec76159
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81f81f88e30714043a121630f7bf7cb9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,115 @@
|
||||
using UnityEngine;
|
||||
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.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fcedba870c1ea9642a272a53148bb933
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
using Unheimlich.Core.Interaction;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
using Unheimlich.Features.Items;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard
|
||||
{
|
||||
/// <summary>
|
||||
/// Unlocks a clue when the player interacts with this object.
|
||||
/// Can work standalone (extends InteractableBase) or piggyback on an existing InspectableItem.
|
||||
/// </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;
|
||||
private InspectableItem _inspectableItem;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_inspectableItem = GetComponent<InspectableItem>();
|
||||
}
|
||||
|
||||
public override void Interact()
|
||||
{
|
||||
if (_unlockOnlyOnce && _hasUnlocked)
|
||||
{
|
||||
if (_inspectableItem != null)
|
||||
_inspectableItem.Interact();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clueToUnlock == null)
|
||||
{
|
||||
Debug.LogError("[ClueUnlockOnInteraction] No clue data assigned!");
|
||||
if (_inspectableItem != null)
|
||||
_inspectableItem.Interact();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DetectiveBoardManager.Instance != null)
|
||||
{
|
||||
DetectiveBoardManager.Instance.UnlockClue(_clueToUnlock);
|
||||
_hasUnlocked = true;
|
||||
|
||||
}
|
||||
|
||||
if (_inspectableItem != null)
|
||||
{
|
||||
_inspectableItem.Interact();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cca584f48d1f0ab429416470fd01d543
|
||||
@@ -0,0 +1,105 @@
|
||||
using UnityEngine;
|
||||
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.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61b90106a531ea840b670f368e2c8aa9
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.World.CameraSystem;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.OctoArms;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
using Unheimlich.Features.DetectiveBoard.UI;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard
|
||||
{
|
||||
/// <summary>
|
||||
/// Controls player interaction with the detective board.
|
||||
/// </summary>
|
||||
public class DetectiveBoardController : Singleton<DetectiveBoardController>
|
||||
{
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _activationRange = 2f;
|
||||
[SerializeField] private LayerMask _boardLayer;
|
||||
[SerializeField] private CameraPanSettings _playerAlignmentSettings;
|
||||
|
||||
[Header("Virtual Camera")]
|
||||
[SerializeField] private DetectiveBoardVirtualCamera _virtualCamera;
|
||||
|
||||
private bool _isBoardActive;
|
||||
private CameraHeadBob _cameraHeadBob;
|
||||
|
||||
public bool IsBoardActive => _isBoardActive;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
_cameraHeadBob = FindFirstObjectByType<CameraHeadBob>();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if(_virtualCamera == null)
|
||||
_virtualCamera = DetectiveBoardVirtualCamera.Instance;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_isBoardActive)
|
||||
{
|
||||
HandleBoardInteraction();
|
||||
}
|
||||
else
|
||||
{
|
||||
CheckForBoardActivation();
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckForBoardActivation()
|
||||
{
|
||||
if (PlayerController.Instance == null ||
|
||||
PlayerController.Instance.PlayerCamera == null ||
|
||||
DetectiveBoardCanvas.Instance == null)
|
||||
return;
|
||||
|
||||
var camera = PlayerController.Instance.PlayerCamera;
|
||||
var boardTransform = DetectiveBoardCanvas.Instance.transform;
|
||||
|
||||
float distanceToBoard = Vector3.Distance(camera.transform.position, boardTransform.position);
|
||||
if (distanceToBoard > _activationRange)
|
||||
return;
|
||||
|
||||
Ray ray = new Ray(camera.transform.position, camera.transform.forward);
|
||||
if (Physics.Raycast(ray, out RaycastHit hit, _activationRange, _boardLayer))
|
||||
{
|
||||
bool isLookingAtBoard = (hit.transform == boardTransform) ||
|
||||
(boardTransform.IsChildOf(hit.transform)) ||
|
||||
(hit.transform.IsChildOf(boardTransform));
|
||||
|
||||
if (isLookingAtBoard)
|
||||
{
|
||||
var interactAction = InputManager.GetInteractAction();
|
||||
if (interactAction != null && interactAction.WasPressedThisFrame())
|
||||
{
|
||||
ActivateBoard();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ActivateBoard()
|
||||
{
|
||||
if (_isBoardActive ||
|
||||
PlayerController.Instance?.PlayerCamera == null ||
|
||||
PlayerController.Instance?.CameraPanController == null ||
|
||||
DetectiveBoardCanvas.Instance == null ||
|
||||
DetectiveBoardVirtualCamera.Instance == null)
|
||||
{
|
||||
Debug.LogError("[DetectiveBoardController] Missing required references!");
|
||||
return;
|
||||
}
|
||||
|
||||
_isBoardActive = true;
|
||||
|
||||
if (PlayerController.Instance.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = false;
|
||||
}
|
||||
|
||||
OctoIkArmsImproved arms = PlayerController.Instance.GetComponentInChildren<OctoIkArmsImproved>();
|
||||
if (arms != null)
|
||||
{
|
||||
arms.HideArms(3f);
|
||||
}
|
||||
|
||||
InputManager.SetContext(InputManager.InputContext.Listening);
|
||||
|
||||
if (_cameraHeadBob != null)
|
||||
{
|
||||
_cameraHeadBob.SetMode(HeadBobMode.Disabled);
|
||||
}
|
||||
|
||||
if (CrosshairController.Instance != null)
|
||||
{
|
||||
CrosshairController.Instance.Hide();
|
||||
}
|
||||
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
|
||||
var cameraPan = PlayerController.Instance.CameraPanController;
|
||||
var originalSettings = cameraPan.Settings;
|
||||
|
||||
if (_playerAlignmentSettings != null)
|
||||
cameraPan.Settings = _playerAlignmentSettings;
|
||||
|
||||
cameraPan.SaveOriginalState();
|
||||
|
||||
cameraPan.AlignToTarget(DetectiveBoardCanvas.Instance.transform, () =>
|
||||
{
|
||||
DetectiveBoardVirtualCamera.Instance.ActivateFromPlayerPosition(
|
||||
PlayerController.Instance.PlayerCamera,
|
||||
DetectiveBoardCanvas.Instance.transform,
|
||||
DetectiveBoardCanvas.Instance.BoardRenderer
|
||||
);
|
||||
|
||||
if (_playerAlignmentSettings != null)
|
||||
cameraPan.Settings = originalSettings;
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleBoardInteraction()
|
||||
{
|
||||
var exitAction = InputManager.GetExitAction();
|
||||
if (exitAction != null && exitAction.WasPressedThisFrame())
|
||||
{
|
||||
DeactivateBoard();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeactivateBoard()
|
||||
{
|
||||
if (!_isBoardActive || DetectiveBoardVirtualCamera.Instance == null)
|
||||
return;
|
||||
|
||||
_isBoardActive = false;
|
||||
|
||||
DetectiveBoardVirtualCamera.Instance.Deactivate(() =>
|
||||
{
|
||||
var cameraPan = PlayerController.Instance?.CameraPanController;
|
||||
if (cameraPan != null)
|
||||
{
|
||||
OctoIkArmsImproved arms = PlayerController.Instance.GetComponentInChildren<OctoIkArmsImproved>();
|
||||
if (arms != null)
|
||||
{
|
||||
arms.ShowArms(3f);
|
||||
}
|
||||
|
||||
cameraPan.ReturnToOriginalPosition(() =>
|
||||
{
|
||||
if (PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
InputManager.SetContext(InputManager.InputContext.Normal);
|
||||
|
||||
RestoreHeadBob();
|
||||
|
||||
if (CrosshairController.Instance != null)
|
||||
{
|
||||
CrosshairController.Instance.Show();
|
||||
}
|
||||
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
|
||||
private void RestoreHeadBob()
|
||||
{
|
||||
if (_cameraHeadBob == null)
|
||||
return;
|
||||
|
||||
_cameraHeadBob.ClearOverride();
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (DetectiveBoardCanvas.Instance == null)
|
||||
return;
|
||||
|
||||
Gizmos.color = _isBoardActive ? Color.green : Color.yellow;
|
||||
Gizmos.DrawWireSphere(DetectiveBoardCanvas.Instance.transform.position, _activationRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f9f72364623a2a4bb50f02537fd2e72
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard
|
||||
{
|
||||
/// <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 class Connection
|
||||
{
|
||||
public string FromClueID;
|
||||
public string ToClueID;
|
||||
public CertaintyLevel Certainty;
|
||||
public bool IsEliminated;
|
||||
}
|
||||
|
||||
[Header("References")]
|
||||
|
||||
private Dictionary<string, ClueState> _clueStates = new Dictionary<string, ClueState>();
|
||||
private List<Connection> _connections = new List<Connection>();
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
OnClueUnlocked?.Invoke(clueData);
|
||||
}
|
||||
else if (!_clueStates[clueData.ClueID].IsUnlocked)
|
||||
{
|
||||
_clueStates[clueData.ClueID].IsUnlocked = true;
|
||||
OnClueUnlocked?.Invoke(clueData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of all currently unlocked clues.
|
||||
/// Used by DetectiveBoardCanvas to display already-unlocked clues on initialization.
|
||||
/// </summary>
|
||||
public List<ClueData> GetUnlockedClues()
|
||||
{
|
||||
List<ClueData> unlockedClues = new List<ClueData>();
|
||||
foreach (var state in _clueStates.Values)
|
||||
{
|
||||
if (state.IsUnlocked)
|
||||
{
|
||||
unlockedClues.Add(state.Data);
|
||||
}
|
||||
}
|
||||
return unlockedClues;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
public event System.Action<ClueData> OnClueUnlocked;
|
||||
public event System.Action<string, string> OnConnectionCreated;
|
||||
public event System.Action<string> OnClueEliminated;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b187cdccaa498441a752e8f49ff4290
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 466fa804543e82c4483a22466a9bb484
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,170 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
public enum LineColorState
|
||||
{
|
||||
Green = 0,
|
||||
Orange = 1,
|
||||
Red = 2
|
||||
}
|
||||
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class ConnectionLine : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private Image _lineImage;
|
||||
[SerializeField] private Image _outlineImage;
|
||||
[SerializeField] private RectTransform _rectTransform;
|
||||
|
||||
private RectTransform _lineRectTransform;
|
||||
private RectTransform _outlineRectTransform;
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _lineThickness = 3f;
|
||||
[SerializeField] private Color _outlineColor = Color.yellow;
|
||||
|
||||
[Header("Color States")]
|
||||
[SerializeField] private Color _greenColor = Color.green;
|
||||
[SerializeField] private Color _orangeColor = new Color(1f, 0.5f, 0f);
|
||||
[SerializeField] private Color _redColor = Color.red;
|
||||
|
||||
private PostItNode _startNode;
|
||||
private PostItNode _endNode;
|
||||
private LineColorState _colorState = LineColorState.Green;
|
||||
private bool _isHovered;
|
||||
|
||||
public PostItNode StartNode => _startNode;
|
||||
public PostItNode EndNode => _endNode;
|
||||
public LineColorState ColorState => _colorState;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_rectTransform == null)
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
|
||||
if (_lineImage == null)
|
||||
_lineImage = GetComponentInChildren<Image>();
|
||||
|
||||
if (_lineImage != null)
|
||||
{
|
||||
_lineRectTransform = _lineImage.GetComponent<RectTransform>();
|
||||
_lineImage.raycastTarget = true;
|
||||
}
|
||||
|
||||
if (_rectTransform != null)
|
||||
{
|
||||
_rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
_rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
_rectTransform.pivot = new Vector2(0f, 0.5f);
|
||||
}
|
||||
|
||||
if (_lineRectTransform != null)
|
||||
{
|
||||
_lineRectTransform.anchorMin = new Vector2(0f, 0.5f);
|
||||
_lineRectTransform.anchorMax = new Vector2(0f, 0.5f);
|
||||
_lineRectTransform.pivot = new Vector2(0f, 0.5f);
|
||||
}
|
||||
|
||||
if (_outlineImage == null)
|
||||
{
|
||||
_outlineImage = transform.Find("Outline")?.GetComponent<Image>();
|
||||
}
|
||||
|
||||
if (_outlineImage != null)
|
||||
{
|
||||
_outlineRectTransform = _outlineImage.GetComponent<RectTransform>();
|
||||
if (_outlineRectTransform != null)
|
||||
{
|
||||
_outlineRectTransform.anchorMin = new Vector2(0f, 0.5f);
|
||||
_outlineRectTransform.anchorMax = new Vector2(0f, 0.5f);
|
||||
_outlineRectTransform.pivot = new Vector2(0f, 0.5f);
|
||||
}
|
||||
_outlineImage.raycastTarget = false;
|
||||
_outlineImage.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize(PostItNode startNode, PostItNode endNode)
|
||||
{
|
||||
_startNode = startNode;
|
||||
_endNode = endNode;
|
||||
_colorState = LineColorState.Green;
|
||||
|
||||
UpdateColorState();
|
||||
UpdateLine();
|
||||
}
|
||||
|
||||
public void UpdateLine()
|
||||
{
|
||||
if (_startNode == null || _endNode == null || _rectTransform == null)
|
||||
return;
|
||||
|
||||
Vector2 startPos = _startNode.ConnectionPoint.anchoredPosition;
|
||||
Vector2 endPos = _endNode.ConnectionPoint.anchoredPosition;
|
||||
|
||||
Vector2 direction = endPos - startPos;
|
||||
float distance = direction.magnitude;
|
||||
|
||||
_rectTransform.anchoredPosition = startPos;
|
||||
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
_rectTransform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
if (_lineRectTransform != null)
|
||||
{
|
||||
_lineRectTransform.sizeDelta = new Vector2(distance, _lineThickness);
|
||||
}
|
||||
|
||||
if (_outlineRectTransform != null)
|
||||
{
|
||||
_outlineRectTransform.sizeDelta = new Vector2(distance, _lineThickness + 3f);
|
||||
}
|
||||
}
|
||||
|
||||
public void CycleColorState()
|
||||
{
|
||||
_colorState = (LineColorState)(((int)_colorState + 1) % 3);
|
||||
UpdateColorState();
|
||||
}
|
||||
|
||||
public void SetColorState(LineColorState state)
|
||||
{
|
||||
_colorState = state;
|
||||
UpdateColorState();
|
||||
}
|
||||
|
||||
private void UpdateColorState()
|
||||
{
|
||||
if (_lineImage == null)
|
||||
return;
|
||||
|
||||
Color targetColor = _colorState switch
|
||||
{
|
||||
LineColorState.Green => _greenColor,
|
||||
LineColorState.Orange => _orangeColor,
|
||||
LineColorState.Red => _redColor,
|
||||
_ => _greenColor
|
||||
};
|
||||
|
||||
_lineImage.color = targetColor;
|
||||
}
|
||||
|
||||
public void SetHovered(bool hovered)
|
||||
{
|
||||
_isHovered = hovered;
|
||||
|
||||
if (_outlineImage != null)
|
||||
{
|
||||
_outlineImage.gameObject.SetActive(hovered);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ConnectsNodes(PostItNode nodeA, PostItNode nodeB)
|
||||
{
|
||||
return (_startNode == nodeA && _endNode == nodeB) ||
|
||||
(_startNode == nodeB && _endNode == nodeA);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d38fc1a64b1949443b49ec6506174085
|
||||
@@ -0,0 +1,90 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d829d9811842b241b980bd64ab73bff
|
||||
@@ -0,0 +1,443 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Features.Player;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
public class DetectiveBoardCanvas : Singleton<DetectiveBoardCanvas>
|
||||
{
|
||||
[Header("Prefabs")]
|
||||
[SerializeField] private GameObject _postItNodePrefab;
|
||||
[SerializeField] private GameObject _connectionLinePrefab;
|
||||
|
||||
[Header("Layout References")]
|
||||
[SerializeField] private RectTransform _boardArea;
|
||||
[SerializeField] private Renderer _boardRenderer;
|
||||
|
||||
[Header("Auto-Placement Settings")]
|
||||
[SerializeField] private float _leftMargin = 50f;
|
||||
[SerializeField] private float _topMargin = 50f;
|
||||
[SerializeField] private float _nodeSpacing = 20f;
|
||||
[SerializeField] private float _randomRotationRange = 10f;
|
||||
|
||||
[Header("Input")]
|
||||
[SerializeField] private Key _connectModeKey = Key.LeftCtrl;
|
||||
|
||||
private Canvas _canvas;
|
||||
private Camera _targetCamera;
|
||||
private Dictionary<string, PostItNode> _activeNodes = new Dictionary<string, PostItNode>();
|
||||
private List<ConnectionLine> _activeConnections = new List<ConnectionLine>();
|
||||
private PostItNode _selectedNode;
|
||||
private ConnectionLine _hoveredLine;
|
||||
private bool _isConnectMode;
|
||||
private DetectiveBoardSaveData _saveData;
|
||||
private float _currentColumnYOffset = 0f;
|
||||
|
||||
public bool IsConnectMode => _isConnectMode;
|
||||
public RectTransform BoardArea => _boardArea;
|
||||
public Renderer BoardRenderer => _boardRenderer;
|
||||
|
||||
public void OnLineClicked(ConnectionLine line)
|
||||
{
|
||||
if (line != null)
|
||||
{
|
||||
line.CycleColorState();
|
||||
SaveConnectionColorState(line);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveConnectionColorState(ConnectionLine line)
|
||||
{
|
||||
if (line.StartNode == null || line.EndNode == null)
|
||||
return;
|
||||
|
||||
ConnectionData startNodeConnection = line.StartNode.NodeData.Connections.Find(c => c.TargetNodeID == line.EndNode.NodeID);
|
||||
if (startNodeConnection != null)
|
||||
{
|
||||
startNodeConnection.ColorState = line.ColorState;
|
||||
}
|
||||
|
||||
ConnectionData endNodeConnection = line.EndNode.NodeData.Connections.Find(c => c.TargetNodeID == line.StartNode.NodeID);
|
||||
if (endNodeConnection != null)
|
||||
{
|
||||
endNodeConnection.ColorState = line.ColorState;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetHoveredLine(ConnectionLine line)
|
||||
{
|
||||
if (_hoveredLine == line)
|
||||
return;
|
||||
|
||||
if (_hoveredLine != null)
|
||||
{
|
||||
_hoveredLine.SetHovered(false);
|
||||
}
|
||||
|
||||
_hoveredLine = line;
|
||||
|
||||
if (_hoveredLine != null)
|
||||
{
|
||||
_hoveredLine.SetHovered(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by DetectiveBoardInteraction when a node is left-clicked.
|
||||
/// Handles connection creation in connect mode, or selection in normal mode.
|
||||
/// </summary>
|
||||
public void OnNodeClicked(PostItNode node)
|
||||
{
|
||||
if (_isConnectMode)
|
||||
{
|
||||
HandleConnectionModeClick(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedNode = node;
|
||||
UpdateAllNodeSelections();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by DetectiveBoardInteraction when a node is right-clicked.
|
||||
/// Toggles the eliminated state of the node.
|
||||
/// </summary>
|
||||
public void OnNodeRightClicked(PostItNode node)
|
||||
{
|
||||
ToggleNodeEliminated(node);
|
||||
}
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
_canvas = GetComponent<Canvas>();
|
||||
|
||||
if (_canvas != null && _canvas.renderMode == RenderMode.WorldSpace)
|
||||
{
|
||||
if (PlayerController.Instance != null)
|
||||
_targetCamera = PlayerController.Instance.PlayerCamera;
|
||||
|
||||
if (_targetCamera == null)
|
||||
_targetCamera = Camera.main;
|
||||
|
||||
_canvas.worldCamera = _targetCamera;
|
||||
}
|
||||
|
||||
_saveData = new DetectiveBoardSaveData();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
TrySubscribeToManager();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
TrySubscribeToManager();
|
||||
}
|
||||
|
||||
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 Update()
|
||||
{
|
||||
HandleConnectModeInput();
|
||||
}
|
||||
|
||||
private void HandleConnectModeInput()
|
||||
{
|
||||
if (Keyboard.current == null)
|
||||
return;
|
||||
|
||||
bool wasConnectMode = _isConnectMode;
|
||||
_isConnectMode = Keyboard.current[_connectModeKey].isPressed;
|
||||
|
||||
if (wasConnectMode && !_isConnectMode)
|
||||
{
|
||||
_selectedNode = null;
|
||||
UpdateAllNodeSelections();
|
||||
}
|
||||
}
|
||||
|
||||
public void AddClueNode(ClueData clueData)
|
||||
{
|
||||
if (_activeNodes.ContainsKey(clueData.ClueID))
|
||||
return;
|
||||
|
||||
if (_postItNodePrefab == null)
|
||||
{
|
||||
Debug.LogError("[DetectiveBoardCanvas] _postItNodePrefab is null! Assign the prefab in the inspector.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_boardArea == null)
|
||||
{
|
||||
Debug.LogError("[DetectiveBoardCanvas] _boardArea is null! Assign NodesContainer in the inspector.");
|
||||
return;
|
||||
}
|
||||
|
||||
PostItNodeData nodeData = _saveData.GetNodeData(clueData.ClueID);
|
||||
if (nodeData == null)
|
||||
{
|
||||
Vector2 autoPlacedPos = CalculateAutoPlacementPosition();
|
||||
nodeData = new PostItNodeData(clueData.ClueID, autoPlacedPos);
|
||||
_saveData.AddOrUpdateNode(nodeData);
|
||||
}
|
||||
|
||||
GameObject nodeObj = Instantiate(_postItNodePrefab, _boardArea);
|
||||
|
||||
PostItNode node = nodeObj.GetComponent<PostItNode>();
|
||||
|
||||
if (node == null)
|
||||
{
|
||||
Debug.LogError("[DetectiveBoardCanvas] PostItNode component not found on prefab");
|
||||
Destroy(nodeObj);
|
||||
return;
|
||||
}
|
||||
|
||||
node.Initialize(clueData, nodeData);
|
||||
|
||||
float savedRotation = nodeData.Rotation;
|
||||
if (savedRotation == 0f)
|
||||
{
|
||||
savedRotation = Random.Range(-_randomRotationRange, _randomRotationRange);
|
||||
nodeData.Rotation = savedRotation;
|
||||
}
|
||||
node.SetRotation(savedRotation);
|
||||
|
||||
_activeNodes.Add(clueData.ClueID, node);
|
||||
|
||||
RestoreConnectionsForNode(node);
|
||||
}
|
||||
|
||||
private Vector2 CalculateAutoPlacementPosition()
|
||||
{
|
||||
Vector2 canvasSize = _boardArea.rect.size;
|
||||
|
||||
float startX = -canvasSize.x * 0.5f + _leftMargin;
|
||||
float startY = canvasSize.y * 0.5f - _topMargin;
|
||||
|
||||
float estimatedNodeHeight = 150f;
|
||||
|
||||
Vector2 position = new Vector2(startX, startY - _currentColumnYOffset);
|
||||
|
||||
_currentColumnYOffset += estimatedNodeHeight + _nodeSpacing;
|
||||
|
||||
if (_currentColumnYOffset > canvasSize.y - _topMargin * 2)
|
||||
{
|
||||
_currentColumnYOffset = 0f;
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
public void RemoveClueNode(string clueID)
|
||||
{
|
||||
if (!_activeNodes.TryGetValue(clueID, out PostItNode node))
|
||||
return;
|
||||
|
||||
RemoveConnectionsForNode(node);
|
||||
|
||||
_activeNodes.Remove(clueID);
|
||||
Destroy(node.gameObject);
|
||||
}
|
||||
|
||||
private void HandleConnectionModeClick(PostItNode clickedNode)
|
||||
{
|
||||
if (_selectedNode == null)
|
||||
{
|
||||
_selectedNode = clickedNode;
|
||||
UpdateAllNodeSelections();
|
||||
}
|
||||
else if (_selectedNode == clickedNode)
|
||||
{
|
||||
_selectedNode = null;
|
||||
UpdateAllNodeSelections();
|
||||
}
|
||||
else
|
||||
{
|
||||
ToggleConnection(_selectedNode, clickedNode);
|
||||
_selectedNode = null;
|
||||
UpdateAllNodeSelections();
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleConnection(PostItNode nodeA, PostItNode nodeB)
|
||||
{
|
||||
ConnectionLine existingConnection = _activeConnections.Find(c => c.ConnectsNodes(nodeA, nodeB));
|
||||
|
||||
if (existingConnection != null)
|
||||
{
|
||||
RemoveConnection(existingConnection);
|
||||
}
|
||||
else
|
||||
{
|
||||
CreateConnection(nodeA, nodeB);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateConnection(PostItNode startNode, PostItNode endNode)
|
||||
{
|
||||
GameObject lineObj = Instantiate(_connectionLinePrefab, _boardArea);
|
||||
lineObj.transform.SetAsFirstSibling();
|
||||
|
||||
ConnectionLine line = lineObj.GetComponent<ConnectionLine>();
|
||||
if (line == null)
|
||||
{
|
||||
Debug.LogError("[DetectiveBoardCanvas] ConnectionLine component not found on prefab");
|
||||
Destroy(lineObj);
|
||||
return;
|
||||
}
|
||||
|
||||
line.Initialize(startNode, endNode);
|
||||
|
||||
ConnectionData existingConnectionData = startNode.NodeData.Connections.Find(c => c.TargetNodeID == endNode.NodeID);
|
||||
if (existingConnectionData != null)
|
||||
{
|
||||
line.SetColorState(existingConnectionData.ColorState);
|
||||
}
|
||||
|
||||
_activeConnections.Add(line);
|
||||
|
||||
if (existingConnectionData == null)
|
||||
{
|
||||
startNode.NodeData.Connections.Add(new ConnectionData(endNode.NodeID));
|
||||
}
|
||||
|
||||
ConnectionData reverseConnectionData = endNode.NodeData.Connections.Find(c => c.TargetNodeID == startNode.NodeID);
|
||||
if (reverseConnectionData == null)
|
||||
{
|
||||
endNode.NodeData.Connections.Add(new ConnectionData(startNode.NodeID));
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveConnection(ConnectionLine connection)
|
||||
{
|
||||
if (connection.StartNode != null && connection.EndNode != null)
|
||||
{
|
||||
connection.StartNode.NodeData.Connections.RemoveAll(c => c.TargetNodeID == connection.EndNode.NodeID);
|
||||
connection.EndNode.NodeData.Connections.RemoveAll(c => c.TargetNodeID == connection.StartNode.NodeID);
|
||||
}
|
||||
|
||||
_activeConnections.Remove(connection);
|
||||
Destroy(connection.gameObject);
|
||||
}
|
||||
|
||||
private void RemoveConnectionsForNode(PostItNode node)
|
||||
{
|
||||
var connectionsToRemove = _activeConnections
|
||||
.Where(c => c.StartNode == node || c.EndNode == node)
|
||||
.ToList();
|
||||
|
||||
foreach (var connection in connectionsToRemove)
|
||||
{
|
||||
RemoveConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates all connection lines attached to the specified node.
|
||||
/// Called during node dragging to keep connections visually updated.
|
||||
/// </summary>
|
||||
public void UpdateConnectionsForNode(PostItNode node)
|
||||
{
|
||||
foreach (var connection in _activeConnections)
|
||||
{
|
||||
if (connection.StartNode == node || connection.EndNode == node)
|
||||
{
|
||||
connection.UpdateLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreConnectionsForNode(PostItNode node)
|
||||
{
|
||||
if (node.NodeData.Connections == null || node.NodeData.Connections.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (ConnectionData connectionData in node.NodeData.Connections)
|
||||
{
|
||||
if (_activeNodes.TryGetValue(connectionData.TargetNodeID, out PostItNode otherNode))
|
||||
{
|
||||
bool connectionExists = _activeConnections.Any(c => c.ConnectsNodes(node, otherNode));
|
||||
if (!connectionExists)
|
||||
{
|
||||
CreateConnection(node, otherNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleNodeEliminated(PostItNode node)
|
||||
{
|
||||
node.SetEliminated(!node.IsEliminated);
|
||||
}
|
||||
|
||||
private void UpdateAllNodeSelections()
|
||||
{
|
||||
foreach (var node in _activeNodes.Values)
|
||||
{
|
||||
node.SetSelected(node == _selectedNode);
|
||||
}
|
||||
}
|
||||
|
||||
public DetectiveBoardSaveData GetSaveData()
|
||||
{
|
||||
return _saveData;
|
||||
}
|
||||
|
||||
public void LoadSaveData(DetectiveBoardSaveData saveData)
|
||||
{
|
||||
_saveData = saveData;
|
||||
|
||||
foreach (var node in _activeNodes.Values)
|
||||
{
|
||||
PostItNodeData nodeData = _saveData.GetNodeData(node.NodeID);
|
||||
if (nodeData != null)
|
||||
{
|
||||
node.SetPosition(nodeData.Position);
|
||||
node.SetRotation(nodeData.Rotation);
|
||||
node.SetEliminated(nodeData.IsEliminated);
|
||||
}
|
||||
}
|
||||
|
||||
_activeConnections.Clear();
|
||||
foreach (var node in _activeNodes.Values)
|
||||
{
|
||||
RestoreConnectionsForNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: edfcd01879d2cb34495302ef9882965b
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.World.CameraSystem;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles mouse interaction for the detective board World Space canvas.
|
||||
/// 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
|
||||
{
|
||||
[Header("Settings")]
|
||||
[SerializeField] private LayerMask _boardLayer;
|
||||
[SerializeField] private float _minInteractionDistance = 0.1f;
|
||||
[SerializeField] private float _maxInteractionDistance = 10f;
|
||||
|
||||
private Camera _camera;
|
||||
private Canvas _boardCanvas;
|
||||
private RectTransform _boardArea;
|
||||
private DetectiveBoardController _boardController;
|
||||
private DetectiveBoardCanvas _canvasManager;
|
||||
private PostItNode _hoveredNode;
|
||||
private PostItNode _selectedNode;
|
||||
private ConnectionLine _hoveredLine;
|
||||
private bool _isDragging;
|
||||
private Plane _canvasPlane;
|
||||
private Vector3 _dragOffset;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_boardCanvas = GetComponent<Canvas>();
|
||||
_canvasManager = GetComponent<DetectiveBoardCanvas>();
|
||||
_boardController = DetectiveBoardController.Instance;
|
||||
|
||||
if (_canvasManager != null && _canvasManager.BoardArea != null)
|
||||
_boardArea = _canvasManager.BoardArea;
|
||||
|
||||
if (PlayerController.Instance != null)
|
||||
_camera = PlayerController.Instance.PlayerCamera;
|
||||
|
||||
if (_camera == null)
|
||||
_camera = Camera.main;
|
||||
|
||||
UpdateCanvasPlane();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsInBoardInteractionMode())
|
||||
{
|
||||
ClearInteractionState();
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateCameraReference();
|
||||
UpdateCanvasPlane();
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
HandleDrag();
|
||||
}
|
||||
else
|
||||
{
|
||||
HandleHover();
|
||||
HandleLineHover();
|
||||
}
|
||||
|
||||
HandleClick();
|
||||
}
|
||||
|
||||
private void UpdateCameraReference()
|
||||
{
|
||||
if (DetectiveBoardVirtualCamera.Instance != null && DetectiveBoardVirtualCamera.Instance.IsActive)
|
||||
{
|
||||
_camera = DetectiveBoardVirtualCamera.Instance.VirtualCamera;
|
||||
}
|
||||
else if (PlayerController.Instance != null)
|
||||
{
|
||||
_camera = PlayerController.Instance.PlayerCamera;
|
||||
}
|
||||
else if (_camera == null)
|
||||
{
|
||||
_camera = Camera.main;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsInBoardInteractionMode()
|
||||
{
|
||||
bool isActive = _boardController != null && _boardController.IsBoardActive;
|
||||
|
||||
return isActive;
|
||||
}
|
||||
|
||||
private void ClearInteractionState()
|
||||
{
|
||||
if (_hoveredNode != null)
|
||||
{
|
||||
_hoveredNode.SetHovered(false);
|
||||
_hoveredNode = null;
|
||||
}
|
||||
|
||||
if (_hoveredLine != null && _canvasManager != null)
|
||||
{
|
||||
_canvasManager.SetHoveredLine(null);
|
||||
_hoveredLine = null;
|
||||
}
|
||||
|
||||
if (_selectedNode != null && _isDragging)
|
||||
{
|
||||
_selectedNode.SetSelected(false);
|
||||
_selectedNode = null;
|
||||
_isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCanvasPlane()
|
||||
{
|
||||
if (_boardCanvas != null)
|
||||
{
|
||||
Vector3 planeNormal = -_boardCanvas.transform.forward;
|
||||
Vector3 planePosition = _boardCanvas.transform.position;
|
||||
_canvasPlane = new Plane(planeNormal, planePosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleHover()
|
||||
{
|
||||
PostItNode hitNode = RaycastForNode();
|
||||
|
||||
if (hitNode != _hoveredNode)
|
||||
{
|
||||
if (_hoveredNode != null)
|
||||
{
|
||||
_hoveredNode.SetHovered(false);
|
||||
}
|
||||
|
||||
_hoveredNode = hitNode;
|
||||
|
||||
if (_hoveredNode != null)
|
||||
{
|
||||
_hoveredNode.SetHovered(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLineHover()
|
||||
{
|
||||
if (_canvasManager == null || Mouse.current == null)
|
||||
return;
|
||||
|
||||
if (_hoveredNode != null)
|
||||
{
|
||||
if (_hoveredLine != null)
|
||||
{
|
||||
_canvasManager.SetHoveredLine(null);
|
||||
_hoveredLine = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionLine hitLine = RaycastForLine();
|
||||
|
||||
if (hitLine != _hoveredLine)
|
||||
{
|
||||
_canvasManager.SetHoveredLine(hitLine);
|
||||
_hoveredLine = hitLine;
|
||||
}
|
||||
}
|
||||
|
||||
private ConnectionLine RaycastForLine()
|
||||
{
|
||||
if (_camera == null || Mouse.current == null)
|
||||
return null;
|
||||
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
|
||||
GraphicRaycaster raycaster = _boardCanvas?.GetComponent<GraphicRaycaster>();
|
||||
if (raycaster != null && EventSystem.current != null)
|
||||
{
|
||||
var pointerEventData = new PointerEventData(EventSystem.current)
|
||||
{
|
||||
position = mousePos
|
||||
};
|
||||
|
||||
var results = new List<RaycastResult>();
|
||||
raycaster.Raycast(pointerEventData, results);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
ConnectionLine line = result.gameObject.GetComponentInParent<ConnectionLine>();
|
||||
if (line != null)
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
if (Mouse.current == null)
|
||||
return;
|
||||
|
||||
PostItNode hitNode = RaycastForNode();
|
||||
|
||||
if (Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
if (hitNode != null)
|
||||
{
|
||||
HandleLeftClick(hitNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionLine hitLine = RaycastForLine();
|
||||
if (hitLine != null && _canvasManager != null)
|
||||
{
|
||||
_canvasManager.OnLineClicked(hitLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Mouse.current.rightButton.wasPressedThisFrame && hitNode != null && _canvasManager != null)
|
||||
{
|
||||
_canvasManager.OnNodeRightClicked(hitNode);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLeftClick(PostItNode hitNode)
|
||||
{
|
||||
if (_canvasManager != null && _canvasManager.IsConnectMode)
|
||||
{
|
||||
if (hitNode != null)
|
||||
{
|
||||
_canvasManager.OnNodeClicked(hitNode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
_isDragging = false;
|
||||
|
||||
if (_selectedNode != null && hitNode == _selectedNode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedNode != null)
|
||||
{
|
||||
_selectedNode.SetSelected(false);
|
||||
}
|
||||
|
||||
if (hitNode != null)
|
||||
{
|
||||
_selectedNode = hitNode;
|
||||
_selectedNode.SetSelected(true);
|
||||
_isDragging = true;
|
||||
CalculateDragOffset();
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedNode = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (hitNode != null)
|
||||
{
|
||||
if (_selectedNode != null && _selectedNode != hitNode)
|
||||
{
|
||||
_selectedNode.SetSelected(false);
|
||||
}
|
||||
|
||||
_selectedNode = hitNode;
|
||||
_selectedNode.SetSelected(true);
|
||||
_isDragging = true;
|
||||
CalculateDragOffset();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_selectedNode != null)
|
||||
{
|
||||
_selectedNode.SetSelected(false);
|
||||
_selectedNode = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDrag()
|
||||
{
|
||||
if (_selectedNode == null || _camera == null)
|
||||
return;
|
||||
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
|
||||
|
||||
if (_canvasPlane.Raycast(ray, out float enter))
|
||||
{
|
||||
Vector3 worldHitPoint = ray.GetPoint(enter);
|
||||
Vector3 newWorldPos = worldHitPoint + _dragOffset;
|
||||
|
||||
Vector3 localPos = _boardCanvas.transform.InverseTransformPoint(newWorldPos);
|
||||
Vector2 clampedPos = ClampPositionToBoard(new Vector2(localPos.x, localPos.y));
|
||||
|
||||
_selectedNode.SetPosition(clampedPos);
|
||||
|
||||
if (_canvasManager != null)
|
||||
{
|
||||
_canvasManager.UpdateConnectionsForNode(_selectedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 ClampPositionToBoard(Vector2 position)
|
||||
{
|
||||
if (_boardArea == null)
|
||||
return position;
|
||||
|
||||
Rect boardRect = _boardArea.rect;
|
||||
float halfWidth = boardRect.width * 0.5f;
|
||||
float halfHeight = boardRect.height * 0.5f;
|
||||
|
||||
float clampedX = Mathf.Clamp(position.x, -halfWidth, halfWidth);
|
||||
float clampedY = Mathf.Clamp(position.y, -halfHeight, halfHeight);
|
||||
|
||||
return new Vector2(clampedX, clampedY);
|
||||
}
|
||||
|
||||
private void CalculateDragOffset()
|
||||
{
|
||||
if (_selectedNode == null || _camera == null)
|
||||
return;
|
||||
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
|
||||
|
||||
if (_canvasPlane.Raycast(ray, out float enter))
|
||||
{
|
||||
Vector3 worldHitPoint = ray.GetPoint(enter);
|
||||
Vector3 currentWorldPos = _selectedNode.transform.position;
|
||||
_dragOffset = currentWorldPos - worldHitPoint;
|
||||
}
|
||||
}
|
||||
|
||||
private PostItNode RaycastForNode()
|
||||
{
|
||||
if (_camera == null || Mouse.current == null)
|
||||
return null;
|
||||
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
|
||||
GraphicRaycaster raycaster = _boardCanvas?.GetComponent<GraphicRaycaster>();
|
||||
if (raycaster != null && EventSystem.current != null)
|
||||
{
|
||||
var pointerEventData = new PointerEventData(EventSystem.current)
|
||||
{
|
||||
position = mousePos
|
||||
};
|
||||
|
||||
var results = new List<RaycastResult>();
|
||||
raycaster.Raycast(pointerEventData, results);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
PostItNode node = result.gameObject.GetComponentInParent<PostItNode>();
|
||||
if (node != null)
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
|
||||
|
||||
if (Physics.Raycast(ray, out RaycastHit hit, _maxInteractionDistance, _boardLayer))
|
||||
{
|
||||
float distance = hit.distance;
|
||||
if (distance >= _minInteractionDistance)
|
||||
{
|
||||
return hit.collider.GetComponentInParent<PostItNode>();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17bf00a333a0bc9409c21009e3ac6aea
|
||||
@@ -0,0 +1,132 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
public class PostItNode : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
[SerializeField] private RectTransform _rectTransform;
|
||||
[SerializeField] private Image _background;
|
||||
[SerializeField] private TextMeshProUGUI _titleText;
|
||||
[SerializeField] private TextMeshProUGUI _descriptionText;
|
||||
[SerializeField] private Image _iconImage;
|
||||
[SerializeField] private Image _selectionOutline;
|
||||
|
||||
[Header("Effects")]
|
||||
[SerializeField] private PostItScribbleEffect _scribbleEffect;
|
||||
|
||||
[Header("Connection Points")]
|
||||
[SerializeField] private RectTransform _connectionPoint;
|
||||
|
||||
private ClueData _clueData;
|
||||
private PostItNodeData _nodeData;
|
||||
private bool _isEliminated;
|
||||
private bool _isSelected;
|
||||
private Color _originalColor;
|
||||
|
||||
public string NodeID => _clueData?.ClueID;
|
||||
public RectTransform RectTransform => _rectTransform;
|
||||
public RectTransform ConnectionPoint => _rectTransform;
|
||||
public ClueData ClueData => _clueData;
|
||||
public PostItNodeData NodeData => _nodeData;
|
||||
public bool IsEliminated => _isEliminated;
|
||||
public bool IsSelected => _isSelected;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_rectTransform == null)
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
|
||||
if (_scribbleEffect == null)
|
||||
_scribbleEffect = GetComponent<PostItScribbleEffect>();
|
||||
|
||||
if (_selectionOutline != null)
|
||||
_selectionOutline.enabled = false;
|
||||
}
|
||||
|
||||
public void Initialize(ClueData clueData, PostItNodeData nodeData)
|
||||
{
|
||||
_clueData = clueData;
|
||||
_nodeData = nodeData;
|
||||
|
||||
if (_titleText != null)
|
||||
_titleText.text = clueData.Title;
|
||||
|
||||
if (_descriptionText != null)
|
||||
_descriptionText.text = clueData.Description;
|
||||
|
||||
if (_iconImage != null && clueData.Icon != null)
|
||||
{
|
||||
_iconImage.sprite = clueData.Icon;
|
||||
_iconImage.enabled = true;
|
||||
}
|
||||
else if (_iconImage != null)
|
||||
{
|
||||
_iconImage.enabled = false;
|
||||
}
|
||||
|
||||
if (_background != null)
|
||||
{
|
||||
_originalColor = clueData.NodeColor;
|
||||
_background.color = _originalColor;
|
||||
}
|
||||
|
||||
SetPosition(nodeData.Position);
|
||||
SetEliminated(nodeData.IsEliminated);
|
||||
}
|
||||
|
||||
public void SetPosition(Vector2 position)
|
||||
{
|
||||
if (_rectTransform != null)
|
||||
{
|
||||
_rectTransform.anchoredPosition = position;
|
||||
if (_nodeData != null)
|
||||
_nodeData.Position = position;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetRotation(float rotation)
|
||||
{
|
||||
if (_rectTransform != null)
|
||||
{
|
||||
_rectTransform.localRotation = Quaternion.Euler(0, 0, rotation);
|
||||
if (_nodeData != null)
|
||||
_nodeData.Rotation = rotation;
|
||||
}
|
||||
}
|
||||
|
||||
public float GetRotation()
|
||||
{
|
||||
return _rectTransform != null ? _rectTransform.localEulerAngles.z : 0f;
|
||||
}
|
||||
|
||||
public void SetEliminated(bool eliminated)
|
||||
{
|
||||
_isEliminated = eliminated;
|
||||
|
||||
if (_scribbleEffect != null)
|
||||
{
|
||||
_scribbleEffect.SetEliminated(eliminated);
|
||||
}
|
||||
|
||||
if (_nodeData != null)
|
||||
_nodeData.IsEliminated = eliminated;
|
||||
}
|
||||
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
_isSelected = selected;
|
||||
}
|
||||
|
||||
public void SetHovered(bool hovered)
|
||||
{
|
||||
if (_selectionOutline != null)
|
||||
{
|
||||
_selectionOutline.enabled = hovered;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a398093f79b17c04e81fe88b509b1319
|
||||
@@ -0,0 +1,135 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.DetectiveBoard.UI
|
||||
{
|
||||
public class PostItScribbleEffect : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Material _scribbleMaterialTemplate;
|
||||
[SerializeField] private RectTransform _targetRectTransform;
|
||||
|
||||
private Image _scribbleOverlay;
|
||||
private Material _scribbleMaterialInstance;
|
||||
private bool _overlayCreated = false;
|
||||
private bool _isEliminated = false;
|
||||
|
||||
private static readonly int ProgressID = Shader.PropertyToID("_Progress");
|
||||
private int _scribbleTweenId = -1;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_targetRectTransform == null)
|
||||
_targetRectTransform = GetComponent<RectTransform>();
|
||||
}
|
||||
|
||||
private void CreateScribbleOverlay()
|
||||
{
|
||||
if (_overlayCreated || _scribbleMaterialTemplate == null || _targetRectTransform == null)
|
||||
return;
|
||||
|
||||
GameObject overlayObject = new GameObject("ScribbleOverlay");
|
||||
overlayObject.transform.SetParent(_targetRectTransform, false);
|
||||
|
||||
_scribbleOverlay = overlayObject.AddComponent<Image>();
|
||||
_scribbleOverlay.raycastTarget = false;
|
||||
|
||||
RectTransform overlayRect = overlayObject.GetComponent<RectTransform>();
|
||||
|
||||
overlayRect.anchorMin = Vector2.zero;
|
||||
overlayRect.anchorMax = Vector2.one;
|
||||
overlayRect.sizeDelta = Vector2.zero;
|
||||
overlayRect.anchoredPosition = Vector2.zero;
|
||||
|
||||
_scribbleMaterialInstance = new Material(_scribbleMaterialTemplate);
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, 0f);
|
||||
_scribbleOverlay.material = _scribbleMaterialInstance;
|
||||
|
||||
_scribbleOverlay.gameObject.SetActive(false);
|
||||
|
||||
_overlayCreated = true;
|
||||
}
|
||||
|
||||
public void SetEliminated(bool eliminated)
|
||||
{
|
||||
if (_isEliminated == eliminated)
|
||||
return;
|
||||
|
||||
_isEliminated = eliminated;
|
||||
|
||||
if (eliminated)
|
||||
{
|
||||
ApplyScribble();
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveScribble();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyScribble()
|
||||
{
|
||||
CancelActiveTweens();
|
||||
CreateScribbleOverlay();
|
||||
|
||||
if (_scribbleOverlay == null || _scribbleMaterialInstance == null)
|
||||
return;
|
||||
|
||||
_scribbleOverlay.gameObject.SetActive(true);
|
||||
|
||||
_scribbleTweenId = LeanTween.value(gameObject, 0f, 1f, 0.4f)
|
||||
.setEase(LeanTweenType.easeInOutQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_scribbleMaterialInstance != null)
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, val);
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_scribbleTweenId = -1;
|
||||
}).id;
|
||||
}
|
||||
|
||||
private void RemoveScribble()
|
||||
{
|
||||
if (!_overlayCreated || _scribbleOverlay == null)
|
||||
return;
|
||||
|
||||
if (!_scribbleOverlay.gameObject.activeSelf)
|
||||
return;
|
||||
|
||||
CancelActiveTweens();
|
||||
|
||||
if (_scribbleMaterialInstance != null)
|
||||
{
|
||||
float currentProgress = _scribbleMaterialInstance.GetFloat(ProgressID);
|
||||
|
||||
_scribbleTweenId = LeanTween.value(gameObject, currentProgress, 0f, 0.3f)
|
||||
.setEase(LeanTweenType.easeOutQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_scribbleMaterialInstance != null)
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, val);
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_scribbleTweenId = -1;
|
||||
if (_scribbleOverlay != null)
|
||||
_scribbleOverlay.gameObject.SetActive(false);
|
||||
}).id;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelActiveTweens()
|
||||
{
|
||||
TweenHelper.CancelTween(ref _scribbleTweenId);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
CancelActiveTweens();
|
||||
|
||||
if (_scribbleMaterialInstance != null)
|
||||
Destroy(_scribbleMaterialInstance);
|
||||
|
||||
if (_scribbleOverlay != null)
|
||||
Destroy(_scribbleOverlay.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 009173206d77d184d86915685410c7d8
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bec178f6742a0e741973f0b0e91015ea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d2498a28ae6dbe4e98260ebaf559261
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,182 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// ScriptableObject configuration for dialogue choice spawn behavior and animations.
|
||||
/// Defines spawn patterns, floating effects, rotation, distance scaling, and focus behavior.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "DialogueAnimationSettings", menuName = "Unheimlich/Animation/Dialogue Animation Settings")]
|
||||
public class DialogueAnimationSettings : ScriptableObject
|
||||
{
|
||||
[Header("Choice Spawn Animation")]
|
||||
[Tooltip("How choices appear in the world")]
|
||||
public ChoiceSpawnMode SpawnMode = ChoiceSpawnMode.AroundAnchor;
|
||||
|
||||
[Tooltip("Default scale multiplier for spawned choices (1.0 = normal size)")]
|
||||
public float DefaultScaleMultiplier = 1.0f;
|
||||
|
||||
[Tooltip("Minimum scale variation multiplier (e.g., 0.5 = 50% of base scale)")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float MinScaleVariation = 0.5f;
|
||||
|
||||
[Tooltip("Maximum scale variation multiplier (e.g., 1.5 = 150% of base scale)")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float MaxScaleVariation = 1.5f;
|
||||
|
||||
[Tooltip("Enable random scale variation for each word")]
|
||||
public bool EnableScaleVariation = false;
|
||||
|
||||
[Header("Spawn Settings")]
|
||||
[Tooltip("Radius around anchor where choices spawn")]
|
||||
public float SpawnRadius = 2f;
|
||||
|
||||
[Tooltip("Height offset from anchor")]
|
||||
public float SpawnHeightOffset = 0f;
|
||||
|
||||
[Tooltip("Should choices face the player?")]
|
||||
public bool FacePlayer = true;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Duration of spawn animation")]
|
||||
public float SpawnAnimationDuration = 0.5f;
|
||||
|
||||
[Tooltip("Spawn animation curve")]
|
||||
public AnimationCurve SpawnCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
[Header("Idle Behavior")]
|
||||
[Tooltip("Should choices float vertically?")]
|
||||
public bool EnableFloating = true;
|
||||
|
||||
[Tooltip("Floating amplitude")]
|
||||
public float FloatingAmplitude = 0.2f;
|
||||
|
||||
[Tooltip("Floating duration (full cycle)")]
|
||||
public float FloatingDuration = 3f;
|
||||
|
||||
[Header("Organic Shake")]
|
||||
[Tooltip("Enable organic shake/drift movement around center point (Heavy Rain style)")]
|
||||
public bool EnableOrganicShake = false;
|
||||
|
||||
[Tooltip("Radius around center point where choices can drift")]
|
||||
public float ShakeRadius = 0.15f;
|
||||
|
||||
[Tooltip("Speed of the shake movement")]
|
||||
public float ShakeSpeed = 1.5f;
|
||||
|
||||
[Tooltip("Smoothness of shake transitions (higher = smoother)")]
|
||||
public float ShakeSmoothness = 2f;
|
||||
|
||||
[Tooltip("Shake speed multiplier for unfocused choices when one is focused (0.3 = 30% speed)")]
|
||||
[Range(0f, 1f)]
|
||||
public float UnfocusedShakeSpeedMultiplier = 0.3f;
|
||||
|
||||
[Header("Ghost Trail Effect")]
|
||||
[Tooltip("Enable ghost/shadow trail effect behind text (Heavy Rain style)")]
|
||||
public bool EnableGhostTrail = false;
|
||||
|
||||
[Tooltip("Time delay for ghost trail in seconds (how far behind the ghost follows)")]
|
||||
public float GhostTrailDelay = 0.15f;
|
||||
|
||||
[Tooltip("Transparency of the ghost trail (0 = invisible, 1 = opaque)")]
|
||||
[Range(0f, 1f)]
|
||||
public float GhostTrailAlpha = 0.3f;
|
||||
|
||||
[Tooltip("Should container rotate continuously?")]
|
||||
public bool EnableRotation = true;
|
||||
|
||||
[Tooltip("Axis of rotation (Forward = Z, Up = Y, Right = X)")]
|
||||
public RotationAxis RotationAxis = RotationAxis.Forward;
|
||||
|
||||
[Tooltip("Normal rotation speed when not focused (degrees per second). Higher = faster rotation.")]
|
||||
public float NormalRotationSpeed = 36f;
|
||||
|
||||
[Header("Follow Player")]
|
||||
[Tooltip("Should container follow player position?")]
|
||||
public bool FollowPlayerPosition = false;
|
||||
|
||||
[Tooltip("Should container follow player Y rotation? (keeps choices in front of player)")]
|
||||
public bool FollowPlayerRotation = false;
|
||||
|
||||
[Header("Distance-Based Scaling")]
|
||||
[Tooltip("Should scale and radius adjust based on distance to player?")]
|
||||
public bool EnableDistanceScaling = false;
|
||||
|
||||
[Tooltip("Minimum distance for scaling calculation")]
|
||||
public float MinDistance = 2f;
|
||||
|
||||
[Tooltip("Maximum distance for scaling calculation")]
|
||||
public float MaxDistance = 10f;
|
||||
|
||||
[Tooltip("Scale multiplier at minimum distance")]
|
||||
public float MinScaleMultiplier = 1f;
|
||||
|
||||
[Tooltip("Scale multiplier at maximum distance")]
|
||||
public float MaxScaleMultiplier = 3f;
|
||||
|
||||
[Header("Focus Animation")]
|
||||
[Tooltip("Should choices move toward player when focused?")]
|
||||
public bool MoveTowardPlayerOnFocus = true;
|
||||
|
||||
[Tooltip("Distance from player when focused")]
|
||||
public float FocusDistanceFromPlayer = 1.5f;
|
||||
|
||||
[Tooltip("Rotation speed when a choice is focused (degrees per second). Higher = faster rotation.")]
|
||||
public float FocusRotationSpeed = 60f;
|
||||
|
||||
[Tooltip("Enable camera shake when focusing on a choice")]
|
||||
public bool EnableCameraShakeOnFocus = true;
|
||||
|
||||
[Tooltip("Camera shake intensity when focusing (higher = less shake, lower = more shake)")]
|
||||
public float CameraShakeIntensity = 30f;
|
||||
|
||||
[Tooltip("Camera shake smoothness (0 = instant/jarring, higher = smoother). Controls how fast the camera moves to shake position.")]
|
||||
public float CameraShakeSmoothness = 10f;
|
||||
|
||||
[Tooltip("Scale down multiplier for the FOCUSED choice text (0.9 = 90% size)")]
|
||||
[Range(0f, 1f)]
|
||||
public float FocusedTextScaleMultiplier = 0.9f;
|
||||
|
||||
[Tooltip("Duration of the text scale animation when focusing (in seconds)")]
|
||||
public float FocusedTextScaleDuration = 0.3f;
|
||||
|
||||
[Tooltip("Duration of the text scale reset animation when unfocusing (in seconds)")]
|
||||
public float UnfocusedTextScaleDuration = 0.3f;
|
||||
|
||||
[Header("Sketch & Fade on Selection")]
|
||||
[Tooltip("Enable sketch and fade effect on non-selected words when a choice is focused")]
|
||||
public bool EnableSketchFadeOnFocus = false;
|
||||
|
||||
[Tooltip("Material with sketch shader for TextMeshPro")]
|
||||
public Material SketchMaterial;
|
||||
|
||||
[Tooltip("Duration of the sketch effect before fade (in seconds)")]
|
||||
public float SketchDuration = 0.3f;
|
||||
|
||||
[Tooltip("Duration of the fade out after sketch (in seconds)")]
|
||||
public float FadeOutDuration = 0.5f;
|
||||
|
||||
[Tooltip("Intensity of the sketch effect (0 = no sketch, 1 = full sketch)")]
|
||||
[Range(0f, 1f)]
|
||||
public float SketchIntensity = 0.8f;
|
||||
|
||||
[Tooltip("Final alpha value after fade (0 = invisible, 1 = opaque)")]
|
||||
[Range(0f, 1f)]
|
||||
public float FinalFadeAlpha = 0.2f;
|
||||
}
|
||||
|
||||
public enum ChoiceSpawnMode
|
||||
{
|
||||
AroundAnchor,
|
||||
AtAnchorPosition,
|
||||
InFrontOfPlayer,
|
||||
CircleAroundPlayer
|
||||
}
|
||||
|
||||
public enum RotationAxis
|
||||
{
|
||||
Forward,
|
||||
Up,
|
||||
Right
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aec9b5da9003c646be8289148a4a405
|
||||
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
using OctoCore.Localization;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
[System.Serializable]
|
||||
public class DialogueChoiceData
|
||||
{
|
||||
[Header("Choice Content")]
|
||||
[Tooltip("Localized text displayed on the choice button")]
|
||||
public OctoLocalizedTextData ChoiceText;
|
||||
|
||||
[HideInInspector]
|
||||
public string ChoiceTextOverride;
|
||||
|
||||
[Header("Flow")]
|
||||
[Tooltip("Direct reference to the next DialogueNode")]
|
||||
public DialogueNode NextNode;
|
||||
|
||||
[Header("Conditions (Optional)")]
|
||||
[Tooltip("Required condition to display this choice (optional, for future use)")]
|
||||
public string RequiredCondition;
|
||||
|
||||
public string GetLocalizedChoiceText()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ChoiceTextOverride))
|
||||
return ChoiceTextOverride;
|
||||
|
||||
if (ChoiceText != null)
|
||||
return OctoLocalization.GetLocalizedText(ChoiceText);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return ChoiceText != null && NextNode != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a3b0ea4c9c5bec419eac7026710bfc9
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using OctoCore.Localization;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
[CreateAssetMenu(fileName = "NewConversation", menuName = "Unheimlich/Dialogue/Conversation")]
|
||||
public class DialogueConversation : ScriptableObject
|
||||
{
|
||||
[Header("Speaker Information")]
|
||||
[Tooltip("Localized name of the speaker (common to all texts in this conversation)")]
|
||||
public OctoLocalizedTextData SpeakerName;
|
||||
|
||||
[Header("Display Mode")]
|
||||
[Tooltip("Choose how to display dialogue text: World (3D space) or Screen (Canvas UI)")]
|
||||
public DialogueDisplayMode DisplayMode = DialogueDisplayMode.World;
|
||||
|
||||
[Header("Dialogue Flow")]
|
||||
[Tooltip("Ordered list of dialogue nodes for this conversation")]
|
||||
public List<DialogueNode> DialogueNodes = new List<DialogueNode>();
|
||||
|
||||
public string GetLocalizedSpeakerName()
|
||||
{
|
||||
if (SpeakerName != null)
|
||||
return OctoLocalization.GetLocalizedText(SpeakerName);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public DialogueNode GetFirstNode()
|
||||
{
|
||||
return DialogueNodes != null && DialogueNodes.Count > 0 ? DialogueNodes[0] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83cc2feccc2da50418d37dfef21a2e99
|
||||
@@ -0,0 +1,367 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using OctoCore.Localization;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
#endif
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
public static class DialogueConversationBuilder
|
||||
{
|
||||
// Helper methods kept for compatibility but now create localized text data
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public class DialogueCreatorWindow : EditorWindow
|
||||
{
|
||||
private string _speakerName = "NPC";
|
||||
private string _savePath = "Assets/Unheimlich_POC/Datas/DialogueData";
|
||||
private string _conversationName = "NewConversation";
|
||||
|
||||
private List<NodeTemplate> _nodes = new List<NodeTemplate>();
|
||||
private Vector2 _scrollPosition;
|
||||
|
||||
[System.Serializable]
|
||||
private class NodeTemplate
|
||||
{
|
||||
public string NodeText = "Dialogue text";
|
||||
public List<ChoiceTemplate> Choices = new List<ChoiceTemplate>
|
||||
{
|
||||
new ChoiceTemplate { Text = "Choice 1", TargetNodeIndex = 1 },
|
||||
new ChoiceTemplate { Text = "Choice 2", TargetNodeIndex = 1 }
|
||||
};
|
||||
public bool IsEndNode = false;
|
||||
public bool Foldout = true;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
private class ChoiceTemplate
|
||||
{
|
||||
public string Text = "New choice";
|
||||
public int TargetNodeIndex = 1;
|
||||
}
|
||||
|
||||
[MenuItem("Unheimlich/Tools/Dialogue/Dialogue Creator")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<DialogueCreatorWindow>("Dialogue Creator");
|
||||
window.minSize = new Vector2(500, 600);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_nodes.Count == 0)
|
||||
{
|
||||
_nodes.Add(new NodeTemplate
|
||||
{
|
||||
NodeText = "Hello! Starting dialogue",
|
||||
Choices = new List<ChoiceTemplate>
|
||||
{
|
||||
new ChoiceTemplate { Text = "Option 1", TargetNodeIndex = 1 },
|
||||
new ChoiceTemplate { Text = "Option 2", TargetNodeIndex = 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("Dialogue Creator", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox("Quickly create a complete conversation with all nodes pre-linked!\nTexts will be created as localized assets (you can edit FR/EN later in Inspector).", MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.BeginVertical("box");
|
||||
EditorGUILayout.LabelField("General Configuration", EditorStyles.boldLabel);
|
||||
|
||||
_speakerName = EditorGUILayout.TextField("Speaker Name", _speakerName);
|
||||
_conversationName = EditorGUILayout.TextField("Conversation Name", _conversationName);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("Save Path", GUILayout.Width(150));
|
||||
_savePath = EditorGUILayout.TextField(_savePath);
|
||||
if (GUILayout.Button("...", GUILayout.Width(30)))
|
||||
{
|
||||
string newPath = EditorUtility.OpenFolderPanel("Choose Folder", _savePath, "");
|
||||
if (!string.IsNullOrEmpty(newPath))
|
||||
{
|
||||
if (newPath.StartsWith(Application.dataPath))
|
||||
_savePath = "Assets" + newPath.Substring(Application.dataPath.Length);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("Dialogue Structure", EditorStyles.boldLabel);
|
||||
if (GUILayout.Button("+ Add Node", GUILayout.Width(150)))
|
||||
{
|
||||
NodeTemplate newNode = new NodeTemplate
|
||||
{
|
||||
NodeText = $"Node {_nodes.Count + 1}",
|
||||
Choices = new List<ChoiceTemplate>
|
||||
{
|
||||
new ChoiceTemplate
|
||||
{
|
||||
Text = "Choice 1",
|
||||
TargetNodeIndex = Mathf.Min(_nodes.Count + 1, _nodes.Count)
|
||||
}
|
||||
}
|
||||
};
|
||||
_nodes.Add(newNode);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
|
||||
|
||||
for (int i = 0; i < _nodes.Count; i++)
|
||||
{
|
||||
DrawNodeEditor(i);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Create Conversation", GUILayout.Height(40), GUILayout.Width(200)))
|
||||
{
|
||||
CreateDialogue();
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
}
|
||||
|
||||
private void DrawNodeEditor(int index)
|
||||
{
|
||||
NodeTemplate node = _nodes[index];
|
||||
|
||||
EditorGUILayout.BeginVertical("box");
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
node.Foldout = EditorGUILayout.Foldout(node.Foldout, $"Node {index + 1}" + (index == 0 ? " (START)" : ""), true);
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (_nodes.Count > 1 && GUILayout.Button("X", GUILayout.Width(25)))
|
||||
{
|
||||
_nodes.RemoveAt(index);
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (node.Foldout)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.LabelField("Dialogue Text", EditorStyles.miniBoldLabel);
|
||||
node.NodeText = EditorGUILayout.TextArea(node.NodeText, GUILayout.Height(60));
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
bool isLastNode = index == _nodes.Count - 1;
|
||||
|
||||
if (isLastNode)
|
||||
{
|
||||
EditorGUILayout.HelpBox("Last node = End of conversation (no choices)", MessageType.Info);
|
||||
node.IsEndNode = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField("Available Choices", EditorStyles.miniBoldLabel);
|
||||
|
||||
for (int c = 0; c < node.Choices.Count; c++)
|
||||
{
|
||||
ChoiceTemplate choice = node.Choices[c];
|
||||
|
||||
EditorGUILayout.BeginVertical("box");
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField($"Choice {c + 1}", EditorStyles.boldLabel, GUILayout.Width(60));
|
||||
|
||||
if (GUILayout.Button("-", GUILayout.Width(25)))
|
||||
{
|
||||
node.Choices.RemoveAt(c);
|
||||
break;
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.LabelField("Choice Text:", GUILayout.Width(100));
|
||||
choice.Text = EditorGUILayout.TextField(choice.Text);
|
||||
|
||||
EditorGUILayout.Space(3);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("Target:", GUILayout.Width(100));
|
||||
|
||||
string[] nodeOptions = new string[_nodes.Count];
|
||||
for (int n = 0; n < _nodes.Count; n++)
|
||||
{
|
||||
if (n == index)
|
||||
nodeOptions[n] = $"Node {n + 1} (SELF - Loop)";
|
||||
else if (n == _nodes.Count - 1)
|
||||
nodeOptions[n] = $"Node {n + 1} (END)";
|
||||
else
|
||||
nodeOptions[n] = $"Node {n + 1}";
|
||||
}
|
||||
|
||||
choice.TargetNodeIndex = Mathf.Clamp(choice.TargetNodeIndex, 0, _nodes.Count - 1);
|
||||
|
||||
int selectedIndex = EditorGUILayout.Popup(choice.TargetNodeIndex, nodeOptions);
|
||||
choice.TargetNodeIndex = selectedIndex;
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (choice.TargetNodeIndex == index)
|
||||
EditorGUILayout.HelpBox("! Loop detected: this choice returns to the same node", MessageType.Warning);
|
||||
else if (choice.TargetNodeIndex < index)
|
||||
EditorGUILayout.HelpBox("↩ Going back to Node " + (choice.TargetNodeIndex + 1), MessageType.Info);
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(3);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("+ Add Choice"))
|
||||
{
|
||||
node.Choices.Add(new ChoiceTemplate
|
||||
{
|
||||
Text = $"New choice {node.Choices.Count + 1}",
|
||||
TargetNodeIndex = Mathf.Min(index + 1, _nodes.Count - 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private OctoLocalizedTextData CreateLocalizedTextData(string text, string basePath, string suffix)
|
||||
{
|
||||
OctoLocalizedTextData textData = ScriptableObject.CreateInstance<OctoLocalizedTextData>();
|
||||
textData.Init();
|
||||
|
||||
// Set English text by default
|
||||
textData.Languages.Dictionary["English"] = text;
|
||||
textData.Languages.Dictionary["French"] = ""; // Empty, user will fill later
|
||||
|
||||
string textDataPath = AssetDatabase.GenerateUniqueAssetPath($"{basePath}/text_{suffix}.asset");
|
||||
AssetDatabase.CreateAsset(textData, textDataPath);
|
||||
|
||||
return textData;
|
||||
}
|
||||
|
||||
private void CreateDialogue()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_speakerName) || string.IsNullOrEmpty(_conversationName))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error", "Speaker name and conversation name are required!", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(_savePath))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Error", "Save path is not valid!", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
string folderPath = $"{_savePath}/{_conversationName}";
|
||||
if (!AssetDatabase.IsValidFolder(folderPath))
|
||||
{
|
||||
string parentFolder = _savePath;
|
||||
AssetDatabase.CreateFolder(parentFolder, _conversationName);
|
||||
}
|
||||
|
||||
// Create speaker name localized text
|
||||
OctoLocalizedTextData speakerNameData = CreateLocalizedTextData(_speakerName, folderPath, "SpeakerName");
|
||||
|
||||
List<DialogueNode> createdNodes = new List<DialogueNode>();
|
||||
|
||||
// Create nodes
|
||||
for (int i = 0; i < _nodes.Count; i++)
|
||||
{
|
||||
NodeTemplate template = _nodes[i];
|
||||
DialogueNode node = CreateInstance<DialogueNode>();
|
||||
node.name = $"Node_{i + 1}";
|
||||
|
||||
// Create localized text for dialogue
|
||||
node.DialogueText = CreateLocalizedTextData(template.NodeText, folderPath, $"Node{i + 1}_Text");
|
||||
|
||||
string nodePath = $"{folderPath}/{node.name}.asset";
|
||||
AssetDatabase.CreateAsset(node, nodePath);
|
||||
createdNodes.Add(node);
|
||||
}
|
||||
|
||||
// Link choices
|
||||
for (int i = 0; i < _nodes.Count; i++)
|
||||
{
|
||||
NodeTemplate template = _nodes[i];
|
||||
DialogueNode currentNode = createdNodes[i];
|
||||
|
||||
foreach (ChoiceTemplate choiceTemplate in template.Choices)
|
||||
{
|
||||
int targetIndex = Mathf.Clamp(choiceTemplate.TargetNodeIndex, 0, createdNodes.Count - 1);
|
||||
|
||||
// Create localized text for choice
|
||||
OctoLocalizedTextData choiceTextData = CreateLocalizedTextData(
|
||||
choiceTemplate.Text,
|
||||
folderPath,
|
||||
$"Node{i + 1}_Choice{template.Choices.IndexOf(choiceTemplate)}"
|
||||
);
|
||||
|
||||
DialogueChoiceData choice = new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = choiceTextData,
|
||||
NextNode = createdNodes[targetIndex]
|
||||
};
|
||||
currentNode.Choices.Add(choice);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(currentNode);
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
DialogueConversation conversation = CreateInstance<DialogueConversation>();
|
||||
conversation.SpeakerName = speakerNameData;
|
||||
conversation.name = _conversationName;
|
||||
conversation.DialogueNodes = createdNodes;
|
||||
|
||||
string conversationPath = $"{folderPath}/{_conversationName}.asset";
|
||||
AssetDatabase.CreateAsset(conversation, conversationPath);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
EditorGUIUtility.PingObject(conversation);
|
||||
Selection.activeObject = conversation;
|
||||
|
||||
EditorUtility.DisplayDialog(
|
||||
"Success",
|
||||
$"Conversation '{_conversationName}' created with {_nodes.Count} nodes!\n\n" +
|
||||
$"Path: {folderPath}\n\n" +
|
||||
$"📝 Note: All texts are in English by default.\n" +
|
||||
$"Open each DialogueNode in the Inspector to add French translations!",
|
||||
"OK"
|
||||
);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 863ff46c7eaea694e97b5e51782b7119
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
public enum DialogueDisplayMode
|
||||
{
|
||||
World,
|
||||
Screen
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b10588959f3745441b157488d7a8f6e9
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using OctoCore.Localization;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
[CreateAssetMenu(fileName = "NewDialogueNode", menuName = "Unheimlich/Dialogue/Dialogue Node")]
|
||||
public class DialogueNode : ScriptableObject
|
||||
{
|
||||
[Header("Content")]
|
||||
[Tooltip("Localized text displayed for this dialogue node")]
|
||||
public OctoLocalizedTextData DialogueText;
|
||||
|
||||
[Header("Choices")]
|
||||
[Tooltip("List of available choices for this dialogue. If empty, this is an end node")]
|
||||
public List<DialogueChoiceData> Choices = new List<DialogueChoiceData>();
|
||||
|
||||
[Header("End Node Settings")]
|
||||
[Tooltip("For end nodes (no choices), delay in seconds before automatic close. 0 = requires player interaction to close")]
|
||||
public float AutoCloseDelay = 3f;
|
||||
|
||||
[Header("Detective Board Integration")]
|
||||
[Tooltip("Clue to unlock when this node is reached. Useful for end nodes to unlock evidence after a conversation")]
|
||||
public ClueData ClueToUnlock;
|
||||
|
||||
public string GetLocalizedDialogueText()
|
||||
{
|
||||
if (DialogueText != null)
|
||||
return OctoLocalization.GetLocalizedText(DialogueText);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public bool IsEndNode()
|
||||
{
|
||||
return Choices == null || Choices.Count == 0;
|
||||
}
|
||||
|
||||
public bool HasValidChoices()
|
||||
{
|
||||
if (Choices == null || Choices.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (var choice in Choices)
|
||||
{
|
||||
if (choice != null && choice.NextNode != null)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5adec41ffe2e0f4d83e2e75e31781d6
|
||||
@@ -0,0 +1,123 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.Data
|
||||
{
|
||||
[CreateAssetMenu(fileName = "NewObjectDescriptionSettings", menuName = "Unheimlich/Animation/Object Description Settings")]
|
||||
public class ObjectDescriptionSettings : ScriptableObject
|
||||
{
|
||||
[Header("Spawn Settings")]
|
||||
[Tooltip("Spawn mode for floating words")]
|
||||
public ChoiceSpawnMode SpawnMode = ChoiceSpawnMode.AroundAnchor;
|
||||
|
||||
[Tooltip("Number of floating words to spawn around the object")]
|
||||
[Range(1, 10)]
|
||||
public int WordCount = 3;
|
||||
|
||||
[Tooltip("Radius around anchor where words spawn")]
|
||||
public float SpawnRadius = 1.5f;
|
||||
|
||||
[Tooltip("Height offset from anchor")]
|
||||
public float SpawnHeightOffset = 0f;
|
||||
|
||||
[Tooltip("Forward offset from anchor (to avoid clipping with object)")]
|
||||
public float ForwardOffset = 0.5f;
|
||||
|
||||
[Tooltip("Tilt angle in degrees (rotation around Right axis for height adjustment)")]
|
||||
[Range(-90f, 90f)]
|
||||
public float TiltAngle = 0f;
|
||||
|
||||
[Tooltip("Should words face the player?")]
|
||||
public bool FacePlayer = true;
|
||||
|
||||
[Tooltip("Duration of spawn animation")]
|
||||
public float SpawnAnimationDuration = 1f;
|
||||
|
||||
[Tooltip("Spawn animation curve")]
|
||||
public AnimationCurve SpawnCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
[Header("Idle Behavior")]
|
||||
[Tooltip("Should words float vertically?")]
|
||||
public bool EnableFloating = true;
|
||||
|
||||
[Tooltip("Floating amplitude")]
|
||||
public float FloatingAmplitude = 0.2f;
|
||||
|
||||
[Tooltip("Floating duration (full cycle)")]
|
||||
public float FloatingDuration = 3f;
|
||||
|
||||
[Tooltip("Enable organic shake/drift movement around center point")]
|
||||
public bool EnableOrganicShake = true;
|
||||
|
||||
[Tooltip("Radius around center point where words can drift")]
|
||||
public float ShakeRadius = 0.15f;
|
||||
|
||||
[Tooltip("Speed of the shake movement")]
|
||||
public float ShakeSpeed = 1.5f;
|
||||
|
||||
[Tooltip("Smoothness of shake transitions (higher = smoother)")]
|
||||
public float ShakeSmoothness = 2f;
|
||||
|
||||
[Header("Ghost Trail Effect")]
|
||||
[Tooltip("Enable ghost/shadow trail effect behind text")]
|
||||
public bool EnableGhostTrail = false;
|
||||
|
||||
[Tooltip("Time delay for ghost trail in seconds")]
|
||||
public float GhostTrailDelay = 0.4f;
|
||||
|
||||
[Tooltip("Transparency of the ghost trail (0 = invisible, 1 = opaque)")]
|
||||
[Range(0f, 1f)]
|
||||
public float GhostTrailAlpha = 0.5f;
|
||||
|
||||
[Header("Rotation")]
|
||||
[Tooltip("Should container rotate continuously?")]
|
||||
public bool EnableRotation = true;
|
||||
|
||||
[Tooltip("Axis of rotation")]
|
||||
public RotationAxis RotationAxis = RotationAxis.Up;
|
||||
|
||||
[Tooltip("Rotation speed (in seconds for a full rotation)")]
|
||||
public float RotationSpeed = 10f;
|
||||
|
||||
[Header("Convergence Behavior")]
|
||||
[Tooltip("Minimum radius when cursor is on object (words converge here)")]
|
||||
public float MinConvergenceRadius = 0.1f;
|
||||
|
||||
[Tooltip("Speed at which words converge to center")]
|
||||
public float ConvergenceSpeed = 5f;
|
||||
|
||||
[Tooltip("Duration of the merge animation when words fuse together")]
|
||||
public float MergeDuration = 0.3f;
|
||||
|
||||
[Tooltip("Scale multiplier during merge animation")]
|
||||
public float MergeScalePulse = 1.2f;
|
||||
|
||||
[Tooltip("If true, merge only happens once and words stay merged. If false, words can merge/unmerge repeatedly.")]
|
||||
public bool MergeOnce = false;
|
||||
|
||||
[Tooltip("Duration in seconds the merged word stays visible before disappearing (only used if mergeOnce is true)")]
|
||||
public float MergeOnceDisplayDuration = 3f;
|
||||
|
||||
[Header("Player Distance")]
|
||||
[Tooltip("Distance from object at which words appear")]
|
||||
public float ActivationDistance = 5f;
|
||||
|
||||
[Tooltip("Distance beyond which words disappear")]
|
||||
public float DeactivationDistance = 7f;
|
||||
|
||||
[Header("Visual Settings")]
|
||||
[Tooltip("Enable distance-based scaling")]
|
||||
public bool EnableDistanceScaling = true;
|
||||
|
||||
[Tooltip("Minimum distance for scaling calculation")]
|
||||
public float MinDistance = 2f;
|
||||
|
||||
[Tooltip("Maximum distance for scaling calculation")]
|
||||
public float MaxDistance = 10f;
|
||||
|
||||
[Tooltip("Scale multiplier at minimum distance")]
|
||||
public float MinScaleMultiplier = 1f;
|
||||
|
||||
[Tooltip("Scale multiplier at maximum distance")]
|
||||
public float MaxScaleMultiplier = 2.5f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfa0e8eb3803d1c47bbde0fe4fb30a37
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7300e2dc12a6f7b4fac999cb3d7b07b5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,112 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
|
||||
[CustomEditor(typeof(DialogueConversation))]
|
||||
public class DialogueConversationEditor : Editor
|
||||
{
|
||||
private SerializedProperty _speakerNameProp;
|
||||
private SerializedProperty _displayModeProp;
|
||||
private SerializedProperty _dialogueNodesProp;
|
||||
|
||||
[MenuItem("Unheimlich/Tools/Dialogue/Refresh All Conversations")]
|
||||
public static void RefreshAllConversations()
|
||||
{
|
||||
string[] guids = AssetDatabase.FindAssets("t:DialogueConversation");
|
||||
int count = 0;
|
||||
|
||||
foreach (string guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
DialogueConversation conv = AssetDatabase.LoadAssetAtPath<DialogueConversation>(path);
|
||||
|
||||
if (conv != null)
|
||||
{
|
||||
EditorUtility.SetDirty(conv);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"<color=green>[Dialogue Refresher]</color> {count} conversations ont été rafraîchies !");
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_speakerNameProp = serializedObject.FindProperty("SpeakerName");
|
||||
_displayModeProp = serializedObject.FindProperty("DisplayMode");
|
||||
_dialogueNodesProp = serializedObject.FindProperty("DialogueNodes");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Conversation Setup", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_speakerNameProp);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
EditorGUILayout.LabelField("Display Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_displayModeProp, new GUIContent("Display Mode"));
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"World: Dialogue text appears in 3D space\n" +
|
||||
"Screen: Dialogue text appears on Canvas UI\n" +
|
||||
"Choices always appear in World space.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Dialogue Flow", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox("The first DialogueNode in the list will be the starting point of the conversation.", MessageType.Info);
|
||||
|
||||
EditorGUILayout.PropertyField(_dialogueNodesProp, new GUIContent("Dialogue Nodes"), true);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
if (GUILayout.Button("Create New Dialogue Node", GUILayout.Height(30)))
|
||||
CreateNewDialogueNode();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
DialogueConversation conversation = (DialogueConversation)target;
|
||||
if (conversation.DialogueNodes != null && conversation.DialogueNodes.Count > 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("Conversation Overview", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"Total Nodes: {conversation.DialogueNodes.Count}");
|
||||
|
||||
DialogueNode firstNode = conversation.GetFirstNode();
|
||||
if (firstNode != null)
|
||||
EditorGUILayout.LabelField($"Starting Node: {firstNode.name}");
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void CreateNewDialogueNode()
|
||||
{
|
||||
DialogueConversation conversation = (DialogueConversation)target;
|
||||
|
||||
string path = AssetDatabase.GetAssetPath(conversation);
|
||||
string directory = System.IO.Path.GetDirectoryName(path);
|
||||
|
||||
DialogueNode newNode = CreateInstance<DialogueNode>();
|
||||
newNode.name = $"Node_{conversation.DialogueNodes.Count + 1}";
|
||||
|
||||
string nodePath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{newNode.name}.asset");
|
||||
AssetDatabase.CreateAsset(newNode, nodePath);
|
||||
|
||||
conversation.DialogueNodes.Add(newNode);
|
||||
|
||||
EditorUtility.SetDirty(conversation);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
EditorGUIUtility.PingObject(newNode);
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3385c171e333c347bcf6f68b953acdb
|
||||
@@ -0,0 +1,112 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
|
||||
[CustomEditor(typeof(DialogueNode))]
|
||||
public class DialogueNodeEditor : Editor
|
||||
{
|
||||
private SerializedProperty _dialogueTextProp;
|
||||
private SerializedProperty _choicesProp;
|
||||
private SerializedProperty _autoCloseDelayProp;
|
||||
private SerializedProperty _clueToUnlockProp;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_dialogueTextProp = serializedObject.FindProperty("DialogueText");
|
||||
_choicesProp = serializedObject.FindProperty("Choices");
|
||||
_autoCloseDelayProp = serializedObject.FindProperty("AutoCloseDelay");
|
||||
_clueToUnlockProp = serializedObject.FindProperty("ClueToUnlock");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Node Content", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_dialogueTextProp);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Choices", EditorStyles.boldLabel);
|
||||
|
||||
DialogueNode node = (DialogueNode)target;
|
||||
|
||||
if (node.IsEndNode())
|
||||
{
|
||||
EditorGUILayout.HelpBox("This node is an end of conversation (no choices).", MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
EditorGUILayout.LabelField("End Node Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_autoCloseDelayProp, new GUIContent("Auto Close Delay (seconds)"));
|
||||
|
||||
float delay = _autoCloseDelayProp.floatValue;
|
||||
if (delay > 0)
|
||||
EditorGUILayout.HelpBox($"The dialogue will automatically close after {delay:F1}s with a fade out.", MessageType.None);
|
||||
else
|
||||
EditorGUILayout.HelpBox("Player must press 'Interact' to close the dialogue.", MessageType.None);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
else if (!node.HasValidChoices())
|
||||
EditorGUILayout.HelpBox("Warning: Some choices don't have a NextNode assigned.", MessageType.Warning);
|
||||
|
||||
EditorGUILayout.PropertyField(_choicesProp, true);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Detective Board Integration", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_clueToUnlockProp, new GUIContent("Clue To Unlock"));
|
||||
|
||||
if (_clueToUnlockProp.objectReferenceValue != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("This clue will be unlocked on the Detective Board when this dialogue node is reached.", MessageType.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("Optional: Assign a ClueData to unlock an evidence on the Detective Board when reaching this node.", MessageType.None);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
||||
if (GUILayout.Button("Add Choice"))
|
||||
{
|
||||
AddNewChoice();
|
||||
}
|
||||
|
||||
if (node.Choices != null && node.Choices.Count > 0)
|
||||
{
|
||||
if (GUILayout.Button("Clear All Choices"))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog("Clear Choices",
|
||||
"Are you sure you want to remove all choices?",
|
||||
"Yes", "Cancel"))
|
||||
{
|
||||
node.Choices.Clear();
|
||||
EditorUtility.SetDirty(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void AddNewChoice()
|
||||
{
|
||||
DialogueNode node = (DialogueNode)target;
|
||||
|
||||
DialogueChoiceData newChoice = new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = null, // Will need to assign OctoLocalizedTextData manually
|
||||
NextNode = null
|
||||
};
|
||||
|
||||
node.Choices.Add(newChoice);
|
||||
EditorUtility.SetDirty(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91d402e79d02c8246b21d7f72df91e84
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f76a0c67d5a1bd4489b6055f3e7077f3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,255 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages gaze-based focus detection for dialogue choices using raycasts.
|
||||
/// </summary>
|
||||
public class DialogueFocusSystem
|
||||
{
|
||||
private readonly DialogueManager _manager;
|
||||
private readonly Camera _mainCamera;
|
||||
private readonly CameraEffects _cameraEffects;
|
||||
|
||||
private ChoiceFocusHandler _currentFocusedHandler;
|
||||
private float _focusTimer = 0f;
|
||||
private float _retentionTimer = 0f;
|
||||
private float _raycastTimer = 0f;
|
||||
private bool _isFocusLocked = false;
|
||||
|
||||
private readonly LayerMask _choiceLayer;
|
||||
private readonly float _focusRange;
|
||||
private readonly float _focusTime;
|
||||
private readonly float _raycastInterval;
|
||||
private readonly float _recoveryDuration;
|
||||
private readonly float _focusRetentionTime;
|
||||
private readonly float _fallbackShakeIntensity;
|
||||
|
||||
public event Action<DialogueChoiceData> OnChoiceMade;
|
||||
|
||||
public DialogueFocusSystem(
|
||||
DialogueManager manager,
|
||||
Camera mainCamera,
|
||||
CameraEffects cameraEffects,
|
||||
LayerMask choiceLayer,
|
||||
float focusRange,
|
||||
float focusTime,
|
||||
float raycastInterval,
|
||||
float recoveryDuration,
|
||||
float focusRetentionTime,
|
||||
float fallbackShakeIntensity)
|
||||
{
|
||||
_manager = manager;
|
||||
_mainCamera = mainCamera;
|
||||
_cameraEffects = cameraEffects;
|
||||
_choiceLayer = choiceLayer;
|
||||
_focusRange = focusRange;
|
||||
_focusTime = focusTime;
|
||||
_raycastInterval = raycastInterval;
|
||||
_recoveryDuration = recoveryDuration;
|
||||
_focusRetentionTime = focusRetentionTime;
|
||||
_fallbackShakeIntensity = fallbackShakeIntensity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the focus detection system. Should be called every frame during active dialogue.
|
||||
/// Performs raycasts at configured intervals to detect choice focus.
|
||||
/// </summary>
|
||||
public void Update()
|
||||
{
|
||||
if (!_manager.IsInDialogue || !_manager.CurrentDialogueUI.AreChoicesVisible())
|
||||
return;
|
||||
|
||||
_raycastTimer += Time.deltaTime;
|
||||
if (_raycastTimer >= _raycastInterval)
|
||||
{
|
||||
CheckForChoiceFocus();
|
||||
_raycastTimer = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs raycast from camera center to detect focused dialogue choices.
|
||||
/// Manages focus state transitions, retention timers, visual feedback, and choice selection.
|
||||
/// Implements stabilization to prevent rapid focus switching between choices.
|
||||
/// </summary>
|
||||
private void CheckForChoiceFocus()
|
||||
{
|
||||
if (_isFocusLocked)
|
||||
{
|
||||
if (_retentionTimer > 0f)
|
||||
_retentionTimer -= Time.deltaTime;
|
||||
else
|
||||
_isFocusLocked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mainCamera == null)
|
||||
return;
|
||||
|
||||
ChoiceFocusHandler hitHandler = null;
|
||||
|
||||
if (RaycastHelper.RaycastFromCameraCenter(_mainCamera, _focusRange, _choiceLayer, out RaycastHit hit))
|
||||
{
|
||||
hitHandler = hit.collider.GetComponent<ChoiceFocusHandler>();
|
||||
|
||||
if (_cameraEffects != null && hitHandler != null)
|
||||
_cameraEffects.ApplyAimAssist(hit.collider.bounds.center);
|
||||
}
|
||||
|
||||
if (hitHandler != _currentFocusedHandler)
|
||||
{
|
||||
_retentionTimer -= Time.deltaTime;
|
||||
|
||||
if (hitHandler != null && _retentionTimer <= 0f)
|
||||
{
|
||||
if (_currentFocusedHandler != null)
|
||||
{
|
||||
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
|
||||
ResetAllUnfocusEffects();
|
||||
}
|
||||
|
||||
_currentFocusedHandler = hitHandler;
|
||||
_focusTimer = 0f;
|
||||
_retentionTimer = _focusRetentionTime;
|
||||
|
||||
if (_cameraEffects != null)
|
||||
_cameraEffects.LockAim(true);
|
||||
|
||||
ApplyUnfocusEffectToOthers(hitHandler);
|
||||
|
||||
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
|
||||
bool shouldRotate = settings == null || settings.EnableRotation;
|
||||
if (shouldRotate && _manager.CurrentDialogueUI != null)
|
||||
{
|
||||
float focusSpeed = settings != null ? settings.FocusRotationSpeed : 60f;
|
||||
_manager.CurrentDialogueUI.RotationAnimation(focusSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (hitHandler != null)
|
||||
{
|
||||
_retentionTimer = _focusRetentionTime;
|
||||
}
|
||||
|
||||
bool isFocusActive = _currentFocusedHandler != null && (_retentionTimer > 0f);
|
||||
|
||||
if (isFocusActive)
|
||||
{
|
||||
_focusTimer += Time.deltaTime;
|
||||
float progress = _focusTimer / _focusTime;
|
||||
|
||||
_currentFocusedHandler.ApplyFocusFeedback(progress);
|
||||
|
||||
DialogueAnimationSettings currentSettings = _manager.CurrentAnimationSettings;
|
||||
bool shouldShake = currentSettings == null || currentSettings.EnableCameraShakeOnFocus;
|
||||
|
||||
if (shouldShake && _cameraEffects != null)
|
||||
{
|
||||
float shakeIntensity = currentSettings != null ? currentSettings.CameraShakeIntensity : _fallbackShakeIntensity;
|
||||
float shakeSmoothness = currentSettings != null ? currentSettings.CameraShakeSmoothness : 10f;
|
||||
_cameraEffects.ShakeCamera(progress / shakeIntensity, shakeSmoothness);
|
||||
}
|
||||
|
||||
if (_focusTimer >= _focusTime)
|
||||
{
|
||||
ChoiceFocusHandler selectedHandler = _currentFocusedHandler;
|
||||
|
||||
if (selectedHandler.DialogueData != null)
|
||||
{
|
||||
OnChoiceMade?.Invoke(selectedHandler.DialogueData);
|
||||
DialogueNode nextNode = selectedHandler.GetNextNode();
|
||||
_manager.GoToNextNode(nextNode);
|
||||
}
|
||||
|
||||
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
|
||||
bool shouldRotate = settings == null || settings.EnableRotation;
|
||||
if (shouldRotate && _manager.CurrentDialogueUI != null)
|
||||
{
|
||||
float normalSpeed = settings != null ? settings.NormalRotationSpeed : 36f;
|
||||
_manager.CurrentDialogueUI.RotationAnimation(normalSpeed);
|
||||
}
|
||||
|
||||
ResetAnimationTimers();
|
||||
ResetAllUnfocusEffects();
|
||||
|
||||
selectedHandler.ResetFeedback(_recoveryDuration);
|
||||
|
||||
if (_cameraEffects != null)
|
||||
_cameraEffects.LockAim(false);
|
||||
}
|
||||
}
|
||||
else if (_currentFocusedHandler != null && _retentionTimer <= 0f)
|
||||
{
|
||||
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
|
||||
ResetAllUnfocusEffects();
|
||||
|
||||
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
|
||||
bool shouldRotate = settings == null || settings.EnableRotation;
|
||||
if (shouldRotate && _manager.CurrentDialogueUI != null)
|
||||
{
|
||||
float normalSpeed = settings != null ? settings.NormalRotationSpeed : 36f;
|
||||
_manager.CurrentDialogueUI.RotationAnimation(normalSpeed);
|
||||
}
|
||||
|
||||
ResetAnimationTimers();
|
||||
|
||||
if (_cameraEffects != null)
|
||||
_cameraEffects.LockAim(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyUnfocusEffectToOthers(ChoiceFocusHandler focusedHandler)
|
||||
{
|
||||
if (_manager.CurrentDialogueUI == null || _manager.CurrentDialogueUI.ChoiceContainer == null)
|
||||
return;
|
||||
|
||||
DialogueAnimationSettings settings = _manager.CurrentAnimationSettings;
|
||||
float shakeSlowdown = settings != null ? settings.UnfocusedShakeSpeedMultiplier : 0.3f;
|
||||
|
||||
foreach (Transform child in _manager.CurrentDialogueUI.ChoiceContainer.transform)
|
||||
{
|
||||
ChoiceFocusHandler handler = child.GetComponent<ChoiceFocusHandler>();
|
||||
if (handler != null && handler != focusedHandler)
|
||||
{
|
||||
handler.SetShakeSpeedMultiplier(shakeSlowdown);
|
||||
handler.ApplySketchFade();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetAllUnfocusEffects()
|
||||
{
|
||||
if (_manager.CurrentDialogueUI == null || _manager.CurrentDialogueUI.ChoiceContainer == null)
|
||||
return;
|
||||
|
||||
foreach (Transform child in _manager.CurrentDialogueUI.ChoiceContainer.transform)
|
||||
{
|
||||
ChoiceFocusHandler handler = child.GetComponent<ChoiceFocusHandler>();
|
||||
if (handler != null)
|
||||
{
|
||||
handler.SetShakeSpeedMultiplier(1f);
|
||||
handler.ResetSketchFade();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetAnimationTimers()
|
||||
{
|
||||
_currentFocusedHandler = null;
|
||||
_focusTimer = 0f;
|
||||
_retentionTimer = 0f;
|
||||
}
|
||||
|
||||
public void UnlockFocus()
|
||||
{
|
||||
_isFocusLocked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22514e9ce0f2e6c4b9f5f718ae139b09
|
||||
@@ -0,0 +1,250 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.DetectiveBoard.Data;
|
||||
using Unheimlich.Features.DetectiveBoard;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using System.Collections.Generic;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue
|
||||
{
|
||||
public class DialogueManager : Singleton<DialogueManager>
|
||||
{
|
||||
|
||||
[Header("State")]
|
||||
public bool IsInDialogue = false;
|
||||
|
||||
[Header("Focus Settings")]
|
||||
[SerializeField] private LayerMask _choiceLayer;
|
||||
[SerializeField] private float _focusRange = 5f;
|
||||
[SerializeField] private float _focusTime = 1.5f;
|
||||
[SerializeField] private float _raycastInterval = 0.05f;
|
||||
|
||||
[Header("Focus Stabilization")]
|
||||
[SerializeField] private float _recoveryDuration = 0.5f;
|
||||
[SerializeField] private float _focusRetentionTime = 0.1f;
|
||||
|
||||
[Tooltip("Fallback camera shake intensity if not set in DialogueAnimationSettings")]
|
||||
[SerializeField] private float _fallbackShakeIntensity = 30f;
|
||||
|
||||
public DialogueAnimationSettings CurrentAnimationSettings => _currentAnimationSettings;
|
||||
public bool IsDialogueActive => IsInDialogue;
|
||||
public Transform CurrentChoiceAnchor => _currentChoiceAnchor;
|
||||
public DialogueUI CurrentDialogueUI => _activeDialogueUI;
|
||||
public object CurrentOwner { get; private set; }
|
||||
public DialogueConversation CurrentConversation => _currentConversation;
|
||||
public DialogueNode CurrentNode => _currentNode;
|
||||
|
||||
public event Action<DialogueChoiceData> OnChoiceMade;
|
||||
public event Action<object> OnDialogueCompleted;
|
||||
|
||||
private DialogueFocusSystem _focusSystem;
|
||||
private Camera _mainCamera;
|
||||
private DialogueAnimationSettings _currentAnimationSettings;
|
||||
private DialogueUI _activeDialogueUI;
|
||||
private Transform _currentChoiceAnchor;
|
||||
|
||||
private DialogueConversation _currentConversation;
|
||||
private DialogueNode _currentNode;
|
||||
private float _autoCloseTimer = 0f;
|
||||
private bool _isWaitingForAutoClose = false;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_mainCamera = Camera.main;
|
||||
|
||||
_focusSystem = new DialogueFocusSystem(
|
||||
this,
|
||||
_mainCamera,
|
||||
PlayerController.Instance?.CameraEffects,
|
||||
_choiceLayer,
|
||||
_focusRange,
|
||||
_focusTime,
|
||||
_raycastInterval,
|
||||
_recoveryDuration,
|
||||
_focusRetentionTime,
|
||||
_fallbackShakeIntensity
|
||||
);
|
||||
|
||||
_focusSystem.OnChoiceMade += (choice) => OnChoiceMade?.Invoke(choice);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsInDialogue)
|
||||
return;
|
||||
|
||||
if (!_activeDialogueUI.AreChoicesVisible())
|
||||
{
|
||||
if (_isWaitingForAutoClose && _currentNode != null && _currentNode.AutoCloseDelay > 0f)
|
||||
{
|
||||
_autoCloseTimer += Time.deltaTime;
|
||||
|
||||
if (_autoCloseTimer >= _currentNode.AutoCloseDelay)
|
||||
{
|
||||
_activeDialogueUI.FadeOutAndHide(() =>
|
||||
{
|
||||
OnDialogueCompleted?.Invoke(CurrentOwner);
|
||||
CleanupDialogue();
|
||||
});
|
||||
|
||||
IsInDialogue = false;
|
||||
_isWaitingForAutoClose = false;
|
||||
|
||||
if (DialogueBlurEffectURP.Instance != null)
|
||||
{
|
||||
DialogueBlurEffectURP.Instance.HideBlur();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var interactAction = InputManager.GetInteractAction();
|
||||
if (interactAction != null && interactAction.WasPressedThisFrame())
|
||||
{
|
||||
EndDialogue();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_focusSystem?.Update();
|
||||
}
|
||||
}
|
||||
|
||||
public void StartConversation(DialogueConversation conversation, Transform choiceAnchor = null, DialogueAnimationSettings animationSettings = null, DialogueUI customDialogueUI = null, object owner = null)
|
||||
{
|
||||
if (conversation == null)
|
||||
{
|
||||
Debug.LogError("Cannot start conversation: conversation is null");
|
||||
return;
|
||||
}
|
||||
|
||||
DialogueNode firstNode = conversation.GetFirstNode();
|
||||
if (firstNode == null)
|
||||
{
|
||||
Debug.LogError($"Cannot start conversation {conversation.name}: no dialogue nodes found");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentConversation = conversation;
|
||||
_currentNode = firstNode;
|
||||
_currentChoiceAnchor = choiceAnchor;
|
||||
_currentAnimationSettings = animationSettings;
|
||||
_activeDialogueUI = customDialogueUI;
|
||||
CurrentOwner = owner;
|
||||
|
||||
ShowCurrentNode();
|
||||
}
|
||||
|
||||
private void ShowCurrentNode()
|
||||
{
|
||||
if (_currentNode == null || _currentConversation == null)
|
||||
return;
|
||||
|
||||
UnlockFocus();
|
||||
IsInDialogue = true;
|
||||
|
||||
if (_currentNode.ClueToUnlock != null)
|
||||
{
|
||||
if (DetectiveBoardManager.Instance != null)
|
||||
{
|
||||
DetectiveBoardManager.Instance.UnlockClue(_currentNode.ClueToUnlock);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[DialogueManager] Cannot unlock clue '{_currentNode.ClueToUnlock.ClueID}': DetectiveBoardManager not found");
|
||||
}
|
||||
}
|
||||
|
||||
if(_currentChoiceAnchor == null)
|
||||
{
|
||||
Debug.LogWarning("No choice anchor found!");
|
||||
return;
|
||||
}
|
||||
Transform anchor = _currentChoiceAnchor;
|
||||
|
||||
string speakerName = _currentConversation.GetLocalizedSpeakerName();
|
||||
string dialogueText = _currentNode.GetLocalizedDialogueText();
|
||||
DialogueDisplayMode displayMode = _currentConversation.DisplayMode;
|
||||
|
||||
_activeDialogueUI.ShowDialogueBox(speakerName, dialogueText, displayMode);
|
||||
|
||||
_autoCloseTimer = 0f;
|
||||
_isWaitingForAutoClose = false;
|
||||
|
||||
if (_currentNode.Choices != null && _currentNode.Choices.Count > 0)
|
||||
{
|
||||
List<DialogueChoiceData> validChoices = new List<DialogueChoiceData>();
|
||||
|
||||
foreach (var choiceData in _currentNode.Choices)
|
||||
{
|
||||
if (choiceData != null && choiceData.IsValid())
|
||||
validChoices.Add(choiceData);
|
||||
}
|
||||
|
||||
if (validChoices.Count > 0)
|
||||
_activeDialogueUI.ShowChoices(validChoices, anchor, _currentAnimationSettings);
|
||||
}
|
||||
else
|
||||
_isWaitingForAutoClose = true;
|
||||
}
|
||||
|
||||
public void GoToNextNode(DialogueNode nextNode)
|
||||
{
|
||||
if (nextNode == null)
|
||||
{
|
||||
OnDialogueCompleted?.Invoke(CurrentOwner);
|
||||
EndDialogue();
|
||||
return;
|
||||
}
|
||||
|
||||
UnlockFocus();
|
||||
|
||||
if (_activeDialogueUI != null)
|
||||
_activeDialogueUI.HideChoices();
|
||||
|
||||
_currentNode = nextNode;
|
||||
ShowCurrentNode();
|
||||
}
|
||||
|
||||
public void EndDialogue()
|
||||
{
|
||||
IsInDialogue = false;
|
||||
|
||||
if (_activeDialogueUI != null)
|
||||
_activeDialogueUI.HideDialogueBox();
|
||||
|
||||
if (DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.HideBlur();
|
||||
|
||||
CleanupDialogue();
|
||||
}
|
||||
|
||||
private void CleanupDialogue()
|
||||
{
|
||||
_currentChoiceAnchor = null;
|
||||
_currentAnimationSettings = null;
|
||||
_activeDialogueUI = null;
|
||||
CurrentOwner = null;
|
||||
|
||||
_currentConversation = null;
|
||||
_currentNode = null;
|
||||
_autoCloseTimer = 0f;
|
||||
_isWaitingForAutoClose = false;
|
||||
}
|
||||
|
||||
public void UnlockFocus()
|
||||
{
|
||||
_focusSystem?.UnlockFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd83d1e8e266cff41b8a5c2928f1f80f
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue
|
||||
{
|
||||
public class DialogueStateHandler
|
||||
{
|
||||
public bool IsCompleted { get; private set; }
|
||||
public bool HasPausedDialogue => _pausedConversation != null && _pausedNode != null;
|
||||
|
||||
private DialogueConversation _pausedConversation;
|
||||
private DialogueNode _pausedNode;
|
||||
private Transform _pausedChoiceAnchor;
|
||||
private DialogueAnimationSettings _pausedAnimationSettings;
|
||||
private DialogueUI _pausedDialogueUI;
|
||||
private object _owner;
|
||||
|
||||
public DialogueStateHandler(object owner)
|
||||
{
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public void PauseDialogue()
|
||||
{
|
||||
if (DialogueManager.Instance == null || !DialogueManager.Instance.IsDialogueActive)
|
||||
return;
|
||||
|
||||
DialogueManager dialogueManager = DialogueManager.Instance;
|
||||
|
||||
_pausedConversation = dialogueManager.CurrentConversation;
|
||||
_pausedNode = dialogueManager.CurrentNode;
|
||||
_pausedChoiceAnchor = dialogueManager.CurrentChoiceAnchor;
|
||||
_pausedAnimationSettings = dialogueManager.CurrentAnimationSettings;
|
||||
_pausedDialogueUI = dialogueManager.CurrentDialogueUI;
|
||||
|
||||
DialogueManager.Instance.EndDialogue();
|
||||
}
|
||||
|
||||
public void ResumeDialogue()
|
||||
{
|
||||
if (!HasPausedDialogue || DialogueManager.Instance == null)
|
||||
return;
|
||||
|
||||
DialogueManager.Instance.StartConversation(
|
||||
_pausedConversation,
|
||||
_pausedChoiceAnchor,
|
||||
_pausedAnimationSettings,
|
||||
_pausedDialogueUI,
|
||||
_owner
|
||||
);
|
||||
|
||||
ClearPausedDialogue();
|
||||
}
|
||||
|
||||
public void MarkAsCompleted()
|
||||
{
|
||||
IsCompleted = true;
|
||||
ClearPausedDialogue();
|
||||
}
|
||||
|
||||
public void AllowRetrigger()
|
||||
{
|
||||
IsCompleted = false;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsCompleted = false;
|
||||
ClearPausedDialogue();
|
||||
}
|
||||
|
||||
private void ClearPausedDialogue()
|
||||
{
|
||||
_pausedConversation = null;
|
||||
_pausedNode = null;
|
||||
_pausedChoiceAnchor = null;
|
||||
_pausedAnimationSettings = null;
|
||||
_pausedDialogueUI = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b328dc63103543940898cfa2dbd4ec36
|
||||
@@ -0,0 +1,185 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
[Header("Dialogue Reference")]
|
||||
[Tooltip("DialogueConversation to use for this trigger zone")]
|
||||
[SerializeField] private DialogueConversation _conversation;
|
||||
|
||||
[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";
|
||||
|
||||
[Header("Distance Check")]
|
||||
[Tooltip("Pause dialogue if player exits the trigger zone")]
|
||||
[SerializeField] private bool _pauseOnExit = true;
|
||||
|
||||
[Header("Floating Word Anchor")]
|
||||
[SerializeField] private Transform _floatingWordAnchor;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private DialogueAnimationSettings _animationSettings;
|
||||
|
||||
[Header("UI Reference")]
|
||||
[Tooltip("Local DialogueUI for this trigger zone. Uses DialogueManager's UI if null")]
|
||||
[SerializeField] private DialogueUI _localDialogueUI;
|
||||
|
||||
[Header("Blur Effect")]
|
||||
[Tooltip("Enable blur effect when dialogue starts")]
|
||||
[SerializeField] private bool _enableBlurEffect = true;
|
||||
|
||||
private bool _hasTriggered = false;
|
||||
private Collider _triggerCollider;
|
||||
private DialogueStateHandler _dialogueState;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_triggerCollider = GetComponent<Collider>();
|
||||
_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)
|
||||
{
|
||||
if (_requirePlayerTag && !other.CompareTag(_playerTag))
|
||||
return;
|
||||
|
||||
if (_dialogueState.IsCompleted && !_isRetriggerable)
|
||||
return;
|
||||
|
||||
if (_dialogueState.HasPausedDialogue)
|
||||
{
|
||||
_dialogueState.ResumeDialogue();
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.ShowBlur();
|
||||
}
|
||||
else if (!_hasTriggered || (_isRetriggerable && _dialogueState.IsCompleted))
|
||||
{
|
||||
TriggerDialogue();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (_requirePlayerTag && !other.CompareTag(_playerTag))
|
||||
return;
|
||||
|
||||
if (_pauseOnExit && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
|
||||
{
|
||||
if ((Object)DialogueManager.Instance.CurrentOwner == this)
|
||||
{
|
||||
_dialogueState.PauseDialogue();
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.HideBlur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerDialogue()
|
||||
{
|
||||
if (DialogueManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("DialogueManager not found in scene!", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_conversation == null)
|
||||
{
|
||||
Debug.LogError($"{gameObject.name}: No conversation assigned!", this);
|
||||
return;
|
||||
}
|
||||
|
||||
_hasTriggered = true;
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
{
|
||||
DialogueBlurEffectURP.Instance.ShowBlur();
|
||||
}
|
||||
|
||||
DialogueManager.Instance.StartConversation(
|
||||
_conversation,
|
||||
_floatingWordAnchor,
|
||||
_animationSettings,
|
||||
_localDialogueUI,
|
||||
this
|
||||
);
|
||||
|
||||
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
|
||||
}
|
||||
|
||||
private void OnDialogueCompleted(object owner)
|
||||
{
|
||||
if ((Object)owner == this)
|
||||
{
|
||||
_dialogueState.MarkAsCompleted();
|
||||
|
||||
if (_isRetriggerable)
|
||||
{
|
||||
_hasTriggered = false;
|
||||
_dialogueState.AllowRetrigger();
|
||||
}
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.HideBlur();
|
||||
}
|
||||
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
|
||||
}
|
||||
|
||||
public void ResetTrigger()
|
||||
{
|
||||
_hasTriggered = false;
|
||||
_dialogueState.Reset();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
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 (_floatingWordAnchor != null)
|
||||
{
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(_floatingWordAnchor.position, 0.2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f33be20afd5d4c843a800ba1d8dd8437
|
||||
@@ -0,0 +1,141 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core.Interaction;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC interactable that starts dialogue conversations.
|
||||
/// Rotates to face player during dialogue and pauses if player moves too far away.
|
||||
/// </summary>
|
||||
public class NPCDialogueTrigger : InteractableBase
|
||||
{
|
||||
[Header("Dialogue Reference")]
|
||||
[Tooltip("DialogueConversation to use for this NPC")]
|
||||
public DialogueConversation Conversation;
|
||||
|
||||
[Header("Trigger Settings")]
|
||||
[Tooltip("Can the dialogue be retriggered after completion")]
|
||||
[SerializeField] private bool _isRetriggerable = true;
|
||||
|
||||
[Header("Floating Word Setting")]
|
||||
[SerializeField] private Transform _floatingWordAnchor;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private DialogueAnimationSettings _animationSettings;
|
||||
|
||||
[Header("UI Reference")]
|
||||
[Tooltip("Local DialogueUI for this NPC. Uses DialogueManager's UI if null")]
|
||||
[SerializeField] private DialogueUI _localDialogueUI;
|
||||
|
||||
[Header("Distance Settings")]
|
||||
[Tooltip("Maximum distance before dialogue pauses")]
|
||||
[SerializeField] private float _maxDialogueDistance = 5f;
|
||||
|
||||
[Header("Blur Effect")]
|
||||
[Tooltip("Enable blur effect when dialogue starts")]
|
||||
[SerializeField] private bool _enableBlurEffect = false;
|
||||
|
||||
public Transform FloatingWordAnchor => _floatingWordAnchor;
|
||||
|
||||
private bool _isDialogueActive = false;
|
||||
private DialogueStateHandler _dialogueState;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_dialogueState = new DialogueStateHandler(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InteractionText = "Talk";
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_isDialogueActive && PlayerController.Instance != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, PlayerController.Instance.transform.position);
|
||||
|
||||
if (distance > _maxDialogueDistance && DialogueManager.Instance != null && DialogueManager.Instance.IsDialogueActive)
|
||||
{
|
||||
if ((Object)DialogueManager.Instance.CurrentOwner == this)
|
||||
{
|
||||
_dialogueState.PauseDialogue();
|
||||
_isDialogueActive = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
LookAtPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Interact()
|
||||
{
|
||||
if (DialogueManager.Instance == null)
|
||||
return;
|
||||
|
||||
if (_dialogueState.IsCompleted && !_isRetriggerable)
|
||||
return;
|
||||
|
||||
if (_dialogueState.HasPausedDialogue)
|
||||
{
|
||||
_dialogueState.ResumeDialogue();
|
||||
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
|
||||
_isDialogueActive = true;
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.ShowBlur();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Conversation == null)
|
||||
{
|
||||
Debug.LogWarning($"NPC {gameObject.name} has no conversation configured!");
|
||||
return;
|
||||
}
|
||||
|
||||
DialogueManager.Instance.StartConversation(Conversation, _floatingWordAnchor, _animationSettings, _localDialogueUI, this);
|
||||
DialogueManager.Instance.OnDialogueCompleted += OnDialogueCompleted;
|
||||
_isDialogueActive = true;
|
||||
|
||||
if (_enableBlurEffect && DialogueBlurEffectURP.Instance != null)
|
||||
DialogueBlurEffectURP.Instance.ShowBlur();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDialogueCompleted(object owner)
|
||||
{
|
||||
if ((Object)owner == this)
|
||||
{
|
||||
_dialogueState.MarkAsCompleted();
|
||||
|
||||
if (_isRetriggerable)
|
||||
_dialogueState.AllowRetrigger();
|
||||
|
||||
_isDialogueActive = false;
|
||||
}
|
||||
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.OnDialogueCompleted -= OnDialogueCompleted;
|
||||
}
|
||||
|
||||
public void LookAtPlayer()
|
||||
{
|
||||
if (PlayerController.Instance == null) return;
|
||||
Vector3 lookAtPosition = PlayerController.Instance.transform.position;
|
||||
lookAtPosition.y = transform.position.y;
|
||||
|
||||
transform.LookAt(lookAtPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8696519b7eabaab4dbe80f2a4744cea0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ecb8fa7bbab6024478fc808bee6bcde4
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes UI element always face the main camera.
|
||||
/// Commonly used for world-space UI elements like floating words.
|
||||
/// </summary>
|
||||
public class BillboardUI : MonoBehaviour
|
||||
{
|
||||
private Transform _cameraTransform;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_cameraTransform = Camera.main.transform;
|
||||
|
||||
if (_cameraTransform == null)
|
||||
Debug.LogWarning($"BillboardUI on {gameObject.name}: Main camera not found!");
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_cameraTransform != null)
|
||||
{
|
||||
transform.LookAt(transform.position + _cameraTransform.rotation * Vector3.forward,
|
||||
_cameraTransform.rotation * Vector3.up);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 222282a8dbdde4d418b8c85126583bf1
|
||||
@@ -0,0 +1,567 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages individual dialogue choice button behavior including visual feedback during focus,
|
||||
/// dynamic collider sizing, and smooth animations. Responds to player gaze through the focus system.
|
||||
/// </summary>
|
||||
public class ChoiceFocusHandler : MonoBehaviour
|
||||
{
|
||||
[Header("Components")]
|
||||
[SerializeField] private TextMeshProUGUI _choiceTextComponent;
|
||||
[SerializeField] private TextMeshProUGUI _ghostTextComponent;
|
||||
|
||||
public DialogueChoiceData DialogueData { get; private set; }
|
||||
|
||||
public string GetChoiceText()
|
||||
{
|
||||
return DialogueData?.GetLocalizedChoiceText() ?? string.Empty;
|
||||
}
|
||||
|
||||
public DialogueNode GetNextNode()
|
||||
{
|
||||
return DialogueData?.NextNode;
|
||||
}
|
||||
|
||||
private FloatingWordsSpawner.SpawnConfig _config;
|
||||
private bool _hasConfig;
|
||||
|
||||
[Header("Local Feedback Settings")]
|
||||
[SerializeField] private float _focusLerpSpeed = 10f;
|
||||
[SerializeField] private float _colliderSizeMultiplier = 1.4f;
|
||||
[SerializeField] private float _choiceDistanceFromPlayer = 1f;
|
||||
|
||||
[Header("Text Sizing Settings")]
|
||||
[Tooltip("Maximum width for short texts (1-3 words)")]
|
||||
[SerializeField] private float _shortTextMaxWidth = 80f;
|
||||
[Tooltip("Maximum width for medium texts (4-5 words)")]
|
||||
[SerializeField] private float _mediumTextMaxWidth = 120f;
|
||||
[Tooltip("Maximum width for long texts (6-10 words)")]
|
||||
[SerializeField] private float _longTextMaxWidth = 200f;
|
||||
[Tooltip("Maximum width for very long texts (11+ words)")]
|
||||
[SerializeField] private float _veryLongTextMaxWidth = 250f;
|
||||
[Tooltip("Minimum height for text container")]
|
||||
[SerializeField] private float _minTextHeight = 40f;
|
||||
|
||||
private const int FOCUSED_SORTING_ORDER = 10;
|
||||
private const float MAX_FOCUS_SCALE = 1.2f;
|
||||
|
||||
private BoxCollider _choiceCollider;
|
||||
private Canvas _canvas;
|
||||
private int _originalSortingOrder;
|
||||
private Vector3 _startLocalPos;
|
||||
private float _originalScale;
|
||||
private float _originalTextScale = 1f;
|
||||
private int _textScaleTweenId = -1;
|
||||
private bool _positionCaptured = false;
|
||||
|
||||
private Vector3 _shakeOffset = Vector3.zero;
|
||||
private Vector3 _shakeTarget = Vector3.zero;
|
||||
private float _shakeTimer = 0f;
|
||||
private bool _isShaking = false;
|
||||
private float _shakeSpeedMultiplier = 1f;
|
||||
|
||||
private bool _disableFloatingEffects = false;
|
||||
|
||||
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
|
||||
private float _historyTimer = 0f;
|
||||
private const float HISTORY_SAMPLE_RATE = 0.016f;
|
||||
|
||||
private SketchFadeEffect _sketchFadeEffect;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_canvas = GetComponentInParent<Canvas>();
|
||||
if (_canvas != null)
|
||||
_originalSortingOrder = _canvas.sortingOrder;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_isShaking)
|
||||
UpdateOrganicShake();
|
||||
|
||||
UpdateGhostTrail();
|
||||
}
|
||||
|
||||
private void SetupGhostTrail()
|
||||
{
|
||||
if (!GetEnableGhostTrail())
|
||||
return;
|
||||
|
||||
if (_ghostTextComponent == null && _choiceTextComponent != null)
|
||||
{
|
||||
GameObject ghostObj = new GameObject($"GhostText_{gameObject.name}");
|
||||
ghostObj.transform.SetParent(transform.parent, false);
|
||||
|
||||
ghostObj.transform.localPosition = transform.localPosition;
|
||||
ghostObj.transform.localRotation = transform.localRotation;
|
||||
ghostObj.transform.localScale = transform.localScale;
|
||||
|
||||
ghostObj.layer =gameObject.layer;
|
||||
|
||||
_ghostTextComponent = ghostObj.AddComponent<TextMeshProUGUI>();
|
||||
_ghostTextComponent.font = _choiceTextComponent.font;
|
||||
_ghostTextComponent.fontSize = _choiceTextComponent.fontSize;
|
||||
_ghostTextComponent.alignment = _choiceTextComponent.alignment;
|
||||
_ghostTextComponent.textWrappingMode = _choiceTextComponent.textWrappingMode;
|
||||
_ghostTextComponent.text = _choiceTextComponent.text;
|
||||
|
||||
_ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta;
|
||||
_ghostTextComponent.rectTransform.anchorMin = _choiceTextComponent.rectTransform.anchorMin;
|
||||
_ghostTextComponent.rectTransform.anchorMax = _choiceTextComponent.rectTransform.anchorMax;
|
||||
_ghostTextComponent.rectTransform.pivot = _choiceTextComponent.rectTransform.pivot;
|
||||
|
||||
Color ghostColor = _choiceTextComponent.color;
|
||||
ghostColor.a = GetGhostTrailAlpha();
|
||||
_ghostTextComponent.color = ghostColor;
|
||||
|
||||
_positionHistory.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateGhostTrail()
|
||||
{
|
||||
if (!GetEnableGhostTrail() || _ghostTextComponent == null || _choiceTextComponent == null)
|
||||
return;
|
||||
|
||||
_historyTimer += Time.deltaTime;
|
||||
|
||||
if (_historyTimer >= HISTORY_SAMPLE_RATE)
|
||||
{
|
||||
_positionHistory.Enqueue(transform.localPosition);
|
||||
_historyTimer = 0f;
|
||||
|
||||
int maxHistorySize = Mathf.CeilToInt(GetGhostTrailDelay() / HISTORY_SAMPLE_RATE);
|
||||
|
||||
while (_positionHistory.Count > maxHistorySize && _positionHistory.Count > 0)
|
||||
_positionHistory.Dequeue();
|
||||
}
|
||||
|
||||
if (_positionHistory.Count > 0)
|
||||
{
|
||||
Vector3 delayedPosition = _positionHistory.Peek();
|
||||
_ghostTextComponent.transform.localPosition = delayedPosition;
|
||||
_ghostTextComponent.transform.localRotation = transform.localRotation;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ghostTextComponent.transform.localPosition = transform.localPosition;
|
||||
_ghostTextComponent.transform.localRotation = transform.localRotation;
|
||||
}
|
||||
|
||||
_ghostTextComponent.text = _choiceTextComponent.text;
|
||||
_ghostTextComponent.fontSize = _choiceTextComponent.fontSize;
|
||||
_ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta;
|
||||
|
||||
Color ghostColor = _choiceTextComponent.color;
|
||||
ghostColor.a = GetGhostTrailAlpha();
|
||||
_ghostTextComponent.color = ghostColor;
|
||||
}
|
||||
|
||||
private void UpdateOrganicShake()
|
||||
{
|
||||
if (!GetEnableOrganicShake())
|
||||
return;
|
||||
|
||||
_shakeTimer += Time.deltaTime * GetShakeSpeed() * _shakeSpeedMultiplier;
|
||||
|
||||
if (_shakeTimer >= 1f)
|
||||
{
|
||||
_shakeTimer = 0f;
|
||||
_shakeTarget = Random.insideUnitSphere * GetShakeRadius();
|
||||
}
|
||||
|
||||
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * GetShakeSmoothness());
|
||||
|
||||
if (!_disableFloatingEffects)
|
||||
transform.localPosition = _startLocalPos + _shakeOffset;
|
||||
}
|
||||
|
||||
public Vector3 GetShakeOffset(float convergenceFactor)
|
||||
{
|
||||
return _shakeOffset * convergenceFactor;
|
||||
}
|
||||
|
||||
public void StartOrganicShake()
|
||||
{
|
||||
if (GetEnableOrganicShake())
|
||||
{
|
||||
_isShaking = true;
|
||||
_shakeTimer = 0f;
|
||||
_shakeTarget = Random.insideUnitSphere * GetShakeRadius();
|
||||
}
|
||||
}
|
||||
|
||||
public void StopOrganicShake()
|
||||
{
|
||||
_isShaking = false;
|
||||
_shakeOffset = Vector3.zero;
|
||||
}
|
||||
|
||||
public void SetShakeSpeedMultiplier(float multiplier)
|
||||
{
|
||||
_shakeSpeedMultiplier = Mathf.Max(0f, multiplier);
|
||||
}
|
||||
|
||||
public void SetTextScale(float targetScale, float duration = 0.3f)
|
||||
{
|
||||
if (_choiceTextComponent == null)
|
||||
return;
|
||||
|
||||
TweenHelper.CancelTween(ref _textScaleTweenId);
|
||||
|
||||
_textScaleTweenId = TweenHelper.ScaleLocal(
|
||||
_choiceTextComponent.gameObject,
|
||||
Vector3.one * targetScale,
|
||||
duration,
|
||||
null,
|
||||
LeanTweenType.easeOutQuad
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetTextScale(float duration = 0.3f)
|
||||
{
|
||||
SetTextScale(_originalTextScale, duration);
|
||||
}
|
||||
|
||||
public void HideGhostTrail()
|
||||
{
|
||||
if (_ghostTextComponent != null)
|
||||
_ghostTextComponent.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void ShowGhostTrail()
|
||||
{
|
||||
if (_ghostTextComponent != null)
|
||||
_ghostTextComponent.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies progressive visual feedback as player focuses on this choice.
|
||||
/// Scales the choice, changes color, optionally moves toward player, and adjusts sorting order.
|
||||
/// Progress ranges from 0 (unfocused) to 1 (fully focused).
|
||||
/// </summary>
|
||||
public void ApplyFocusFeedback(float progress)
|
||||
{
|
||||
if (!_positionCaptured)
|
||||
{
|
||||
_startLocalPos = transform.localPosition;
|
||||
_positionCaptured = true;
|
||||
|
||||
LeanTween.cancel(gameObject);
|
||||
StopOrganicShake();
|
||||
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
float textScaleMultiplier = settings != null ? settings.FocusedTextScaleMultiplier : 0.9f;
|
||||
float textScaleDuration = settings != null ? settings.FocusedTextScaleDuration : 0.3f;
|
||||
SetTextScale(_originalTextScale * textScaleMultiplier, textScaleDuration);
|
||||
}
|
||||
|
||||
// transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress);
|
||||
|
||||
bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus();
|
||||
|
||||
if (shouldMoveToPlayer && PlayerController.Instance != null)
|
||||
{
|
||||
float distance = GetFocusDistanceFromPlayer();
|
||||
Vector3 targetPos = PlayerController.Instance.transform.position
|
||||
+ PlayerController.Instance.transform.forward * distance;
|
||||
transform.position = Vector3.Lerp(transform.position, targetPos, Time.deltaTime * _focusLerpSpeed);
|
||||
}
|
||||
|
||||
if (_choiceTextComponent != null)
|
||||
{
|
||||
_choiceTextComponent.color = Color.Lerp(Color.white, Color.red, progress);
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
{
|
||||
float ghostAlpha = GetGhostTrailAlpha();
|
||||
Color ghostColor = Color.Lerp(Color.white, Color.red, progress);
|
||||
ghostColor.a = ghostAlpha;
|
||||
_ghostTextComponent.color = ghostColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (_canvas != null && _canvas.sortingOrder != FOCUSED_SORTING_ORDER)
|
||||
{
|
||||
_canvas.sortingOrder = FOCUSED_SORTING_ORDER;
|
||||
_canvas.overrideSorting = true;
|
||||
}
|
||||
}
|
||||
|
||||
private DialogueAnimationSettings GetAnimationSettings()
|
||||
{
|
||||
if (DialogueManager.Instance != null)
|
||||
return DialogueManager.Instance.CurrentAnimationSettings;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool GetEnableGhostTrail()
|
||||
{
|
||||
if (_hasConfig) return _config.EnableGhostTrail;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null && settings.EnableGhostTrail;
|
||||
}
|
||||
|
||||
private float GetGhostTrailAlpha()
|
||||
{
|
||||
if (_hasConfig) return _config.GhostTrailAlpha;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.GhostTrailAlpha : 0.3f;
|
||||
}
|
||||
|
||||
private float GetGhostTrailDelay()
|
||||
{
|
||||
if (_hasConfig) return _config.GhostTrailDelay;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.GhostTrailDelay : 0.1f;
|
||||
}
|
||||
|
||||
private bool GetEnableOrganicShake()
|
||||
{
|
||||
if (_hasConfig) return _config.EnableOrganicShake;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null && settings.EnableOrganicShake;
|
||||
}
|
||||
|
||||
private float GetShakeRadius()
|
||||
{
|
||||
if (_hasConfig) return _config.ShakeRadius;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.ShakeRadius : 0.01f;
|
||||
}
|
||||
|
||||
private float GetShakeSpeed()
|
||||
{
|
||||
if (_hasConfig) return _config.ShakeSpeed;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.ShakeSpeed : 1f;
|
||||
}
|
||||
|
||||
private float GetShakeSmoothness()
|
||||
{
|
||||
if (_hasConfig) return _config.ShakeSmoothness;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.ShakeSmoothness : 5f;
|
||||
}
|
||||
|
||||
private bool GetMoveTowardPlayerOnFocus()
|
||||
{
|
||||
if (_hasConfig) return _config.MoveTowardPlayerOnFocus;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings == null || settings.MoveTowardPlayerOnFocus;
|
||||
}
|
||||
|
||||
private float GetFocusDistanceFromPlayer()
|
||||
{
|
||||
if (_hasConfig) return _config.FocusDistanceFromPlayer;
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
return settings != null ? settings.FocusDistanceFromPlayer : _choiceDistanceFromPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the choice button with dialogue data.
|
||||
/// Sets up text, creates/configures collider based on text bounds, and caches initial state.
|
||||
/// </summary>
|
||||
public void Setup(DialogueChoiceData dialogueData, FloatingWordsSpawner.SpawnConfig config, bool disableFloatingEffects = false)
|
||||
{
|
||||
DialogueData = dialogueData;
|
||||
_config = config;
|
||||
_hasConfig = true;
|
||||
_disableFloatingEffects = disableFloatingEffects;
|
||||
|
||||
PerformSetup();
|
||||
}
|
||||
|
||||
private void PerformSetup()
|
||||
{
|
||||
if (_choiceTextComponent != null)
|
||||
{
|
||||
_choiceTextComponent.text = GetChoiceText();
|
||||
_choiceTextComponent.ForceMeshUpdate();
|
||||
|
||||
AdjustTextSizeForContent();
|
||||
}
|
||||
|
||||
_choiceCollider = GetComponent<BoxCollider>();
|
||||
if (_choiceCollider == null)
|
||||
{
|
||||
_choiceCollider = gameObject.AddComponent<BoxCollider>();
|
||||
_choiceCollider.isTrigger = true;
|
||||
}
|
||||
|
||||
if (_choiceTextComponent != null)
|
||||
{
|
||||
Vector3 textSize = _choiceTextComponent.bounds.size;
|
||||
_choiceCollider.size = textSize * _colliderSizeMultiplier;
|
||||
_choiceCollider.center = _choiceTextComponent.rectTransform.localPosition;
|
||||
}
|
||||
|
||||
_startLocalPos = transform.localPosition;
|
||||
_originalScale = transform.localScale.x;
|
||||
|
||||
if (_choiceTextComponent != null)
|
||||
_originalTextScale = _choiceTextComponent.transform.localScale.x;
|
||||
|
||||
LeanTween.cancel(gameObject);
|
||||
|
||||
SetupGhostTrail();
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
_ghostTextComponent.text = GetChoiceText();
|
||||
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
if (settings != null && settings.EnableSketchFadeOnFocus && settings.SketchMaterial != null)
|
||||
{
|
||||
_sketchFadeEffect = gameObject.AddComponent<SketchFadeEffect>();
|
||||
_sketchFadeEffect.Initialize(_choiceTextComponent, _ghostTextComponent, settings.SketchMaterial);
|
||||
}
|
||||
|
||||
StartOrganicShake();
|
||||
}
|
||||
|
||||
private void AdjustTextSizeForContent()
|
||||
{
|
||||
if (_choiceTextComponent == null) return;
|
||||
|
||||
string text = GetChoiceText();
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
|
||||
int wordCount = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
|
||||
float maxWidth = GetMaxWidthForWordCount(wordCount);
|
||||
|
||||
string longestWord = GetLongestWord(text);
|
||||
Vector2 longestWordSize = _choiceTextComponent.GetPreferredValues(longestWord, float.MaxValue, 0f);
|
||||
|
||||
if (longestWordSize.x > maxWidth)
|
||||
maxWidth = longestWordSize.x + 10f;
|
||||
|
||||
_choiceTextComponent.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, maxWidth);
|
||||
_choiceTextComponent.ForceMeshUpdate();
|
||||
|
||||
Vector2 textSize = _choiceTextComponent.GetPreferredValues(text, maxWidth, 0f);
|
||||
float finalHeight = Mathf.Max(textSize.y, _minTextHeight);
|
||||
|
||||
Vector2 newSize = new Vector2(maxWidth, finalHeight);
|
||||
|
||||
RectTransform parentRect = transform as RectTransform;
|
||||
if (parentRect != null)
|
||||
parentRect.sizeDelta = newSize;
|
||||
|
||||
ConfigureTextComponent(_choiceTextComponent, newSize);
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
ConfigureTextComponent(_ghostTextComponent, newSize);
|
||||
}
|
||||
|
||||
private void ConfigureTextComponent(TextMeshProUGUI textComponent, Vector2 size)
|
||||
{
|
||||
textComponent.textWrappingMode = TextWrappingModes.Normal;
|
||||
textComponent.overflowMode = TextOverflowModes.Overflow;
|
||||
textComponent.rectTransform.anchorMin = Vector2.one * 0.5f;
|
||||
textComponent.rectTransform.anchorMax = Vector2.one * 0.5f;
|
||||
textComponent.rectTransform.pivot = Vector2.one * 0.5f;
|
||||
textComponent.rectTransform.anchoredPosition = Vector2.zero;
|
||||
textComponent.rectTransform.sizeDelta = size;
|
||||
textComponent.ForceMeshUpdate();
|
||||
}
|
||||
|
||||
private string GetLongestWord(string text)
|
||||
{
|
||||
string[] words = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries);
|
||||
string longest = "";
|
||||
|
||||
foreach (string word in words)
|
||||
{
|
||||
if (word.Length > longest.Length)
|
||||
longest = word;
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
private float GetMaxWidthForWordCount(int wordCount)
|
||||
{
|
||||
if (wordCount <= 2)
|
||||
return _shortTextMaxWidth;
|
||||
else if (wordCount <= 5)
|
||||
return _mediumTextMaxWidth;
|
||||
else if (wordCount <= 10)
|
||||
return _longTextMaxWidth;
|
||||
else
|
||||
return _veryLongTextMaxWidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smoothly returns the choice to its original state after losing focus.
|
||||
/// Animates position back to start, resets scale/color, and unlocks the focus system when complete.
|
||||
/// </summary>
|
||||
public void ResetFeedback(float duration)
|
||||
{
|
||||
LeanTween.cancel(gameObject);
|
||||
|
||||
TweenHelper.CancelTween(ref _textScaleTweenId);
|
||||
|
||||
StopOrganicShake();
|
||||
|
||||
ResetSketchFade();
|
||||
|
||||
TweenHelper.MoveLocal(gameObject, _startLocalPos, duration, () => {
|
||||
_positionCaptured = false;
|
||||
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.UnlockFocus();
|
||||
|
||||
StartOrganicShake();
|
||||
}, LeanTweenType.easeOutQuad);
|
||||
|
||||
transform.localScale = Vector3.one * _originalScale;
|
||||
|
||||
if (_choiceTextComponent != null)
|
||||
{
|
||||
_choiceTextComponent.transform.localScale = Vector3.one * _originalTextScale;
|
||||
_choiceTextComponent.color = Color.white;
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
{
|
||||
float ghostAlpha = GetGhostTrailAlpha();
|
||||
Color ghostColor = Color.white;
|
||||
ghostColor.a = ghostAlpha;
|
||||
_ghostTextComponent.color = ghostColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (_canvas != null)
|
||||
_canvas.sortingOrder = _originalSortingOrder;
|
||||
}
|
||||
|
||||
public void ApplySketchFade()
|
||||
{
|
||||
DialogueAnimationSettings settings = GetAnimationSettings();
|
||||
if (settings == null || !settings.EnableSketchFadeOnFocus || _sketchFadeEffect == null)
|
||||
return;
|
||||
|
||||
_sketchFadeEffect.PlaySketchFadeEffect(
|
||||
settings.SketchDuration,
|
||||
settings.FadeOutDuration,
|
||||
settings.FinalFadeAlpha
|
||||
);
|
||||
}
|
||||
|
||||
public void ResetSketchFade()
|
||||
{
|
||||
if (_sketchFadeEffect != null)
|
||||
_sketchFadeEffect.ResetEffect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abd0f63a44b2272448eb41767c2ce44e
|
||||
@@ -0,0 +1,35 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
public class CrosshairController : Singleton<CrosshairController>
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private GameObject _crosshairObject;
|
||||
|
||||
private bool _isVisible = true;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
if (_crosshairObject != null)
|
||||
_crosshairObject.SetActive(true);
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_crosshairObject != null)
|
||||
_crosshairObject.SetActive(true);
|
||||
_isVisible = true;
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (_crosshairObject != null)
|
||||
_crosshairObject.SetActive(false);
|
||||
_isVisible = false;
|
||||
}
|
||||
|
||||
public bool IsVisible => _isVisible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9adc6263b2493341b02087fb5a00404
|
||||
@@ -0,0 +1,265 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Features.Player;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
public class DialogueBlurEffectURP : Singleton<DialogueBlurEffectURP>
|
||||
{
|
||||
|
||||
[Header("Volume Settings")]
|
||||
[SerializeField] private Volume _globalVolume;
|
||||
|
||||
[Header("Depth of Field (Gaussian)")]
|
||||
[SerializeField] private float _gaussianStart = 0f;
|
||||
[SerializeField] private float _gaussianEnd = 15f;
|
||||
[SerializeField] private float _maxGaussianRadius = 4f;
|
||||
|
||||
[Header("Brightness (Color Adjustments)")]
|
||||
[SerializeField] private float _startPostExposure = 0f;
|
||||
[SerializeField] private float _targetPostExposure = 0.3f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private float _fadeDuration = 1.0f;
|
||||
[SerializeField] private AnimationCurve _fadeCurve;
|
||||
|
||||
[Header("UI Configuration")]
|
||||
[Tooltip("Layer(s) for dialogue (World Space UI) to render without blur")]
|
||||
[SerializeField] private LayerMask _dialogueLayers;
|
||||
[SerializeField] private bool _autoCreateDialogueCamera = true;
|
||||
|
||||
[Header("Hands Configuration")]
|
||||
[Tooltip("Layer for player hands to render on top of dialogue")]
|
||||
[SerializeField] private LayerMask _handsLayer;
|
||||
[SerializeField] private bool _autoCreateHandsCamera = true;
|
||||
|
||||
private DepthOfField _dof;
|
||||
private ColorAdjustments _color;
|
||||
private VolumeProfile _profile;
|
||||
|
||||
private float _current = 0f;
|
||||
private float _target = 0f;
|
||||
private float _start = 0f;
|
||||
private float _timer = 0f;
|
||||
private bool _fading = false;
|
||||
|
||||
private Camera _baseCam;
|
||||
private Camera _dialogueCam;
|
||||
private Camera _handsCam;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
|
||||
|
||||
if (_fadeCurve == null || _fadeCurve.keys.Length == 0)
|
||||
_fadeCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
if (_globalVolume == null)
|
||||
{
|
||||
Debug.LogError("[DialogueBlur] ERROR: Global Volume NOT ASSIGNED in inspector!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_globalVolume.profile == null)
|
||||
{
|
||||
Debug.LogError("[DialogueBlur] ERROR: Global Volume has NO VolumeProfile assigned!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_profile = _globalVolume.profile;
|
||||
|
||||
if (!_profile.TryGet(out _dof))
|
||||
{
|
||||
Debug.LogError("[DialogueBlur] ERROR: Depth Of Field NOT FOUND in Volume Profile!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_profile.TryGet(out _color))
|
||||
{
|
||||
Debug.LogWarning("[DialogueBlur] WARNING: Color Adjustments not found (not critical)");
|
||||
}
|
||||
|
||||
_dof.mode.Override(DepthOfFieldMode.Gaussian);
|
||||
_dof.gaussianStart.Override(_gaussianStart);
|
||||
_dof.gaussianEnd.Override(_gaussianEnd);
|
||||
_dof.gaussianMaxRadius.Override(0f);
|
||||
_dof.active = false;
|
||||
|
||||
if (_color != null)
|
||||
{
|
||||
_color.postExposure.Override(_startPostExposure);
|
||||
_color.active = false;
|
||||
}
|
||||
|
||||
_globalVolume.weight = 0f;
|
||||
_globalVolume.enabled = true;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
_baseCam = PlayerController.Instance.PlayerCamera;
|
||||
if (_autoCreateDialogueCamera)
|
||||
SetupDialogueOverlayCamera();
|
||||
if (_autoCreateHandsCamera)
|
||||
SetupHandsOverlayCamera();
|
||||
}
|
||||
|
||||
void SetupDialogueOverlayCamera()
|
||||
{
|
||||
var baseData = _baseCam.GetComponent<UniversalAdditionalCameraData>();
|
||||
if (baseData == null)
|
||||
baseData = _baseCam.gameObject.AddComponent<UniversalAdditionalCameraData>();
|
||||
|
||||
baseData.renderType = CameraRenderType.Base;
|
||||
baseData.renderPostProcessing = true;
|
||||
|
||||
if (_dialogueLayers.value == 0)
|
||||
Debug.LogWarning("[DialogueBlur] WARNING: dialogueLayers is EMPTY! Ghost words will be blurred!");
|
||||
|
||||
_baseCam.cullingMask &= ~_dialogueLayers;
|
||||
if (_handsLayer.value != 0)
|
||||
_baseCam.cullingMask &= ~_handsLayer;
|
||||
|
||||
var go = new GameObject("Dialogue Camera (Overlay)");
|
||||
go.transform.SetParent(_baseCam.transform);
|
||||
go.transform.localPosition = Vector3.zero;
|
||||
go.transform.localRotation = Quaternion.identity;
|
||||
|
||||
_dialogueCam = go.AddComponent<Camera>();
|
||||
_dialogueCam.CopyFrom(_baseCam);
|
||||
_dialogueCam.clearFlags = CameraClearFlags.Depth;
|
||||
_dialogueCam.cullingMask = _dialogueLayers;
|
||||
|
||||
var overlayData = go.AddComponent<UniversalAdditionalCameraData>();
|
||||
overlayData.renderType = CameraRenderType.Overlay;
|
||||
overlayData.renderPostProcessing = false;
|
||||
|
||||
baseData.cameraStack.Add(_dialogueCam);
|
||||
}
|
||||
|
||||
void SetupHandsOverlayCamera()
|
||||
{
|
||||
if (_handsLayer.value == 0)
|
||||
{
|
||||
Debug.LogWarning("[DialogueBlur] WARNING: handsLayer is EMPTY! Hands camera will not be created.");
|
||||
return;
|
||||
}
|
||||
|
||||
var baseData = _baseCam.GetComponent<UniversalAdditionalCameraData>();
|
||||
if (baseData == null)
|
||||
{
|
||||
Debug.LogError("[DialogueBlur] ERROR: Base camera has no UniversalAdditionalCameraData!");
|
||||
return;
|
||||
}
|
||||
|
||||
var go = new GameObject("Hands Camera (Overlay)");
|
||||
go.transform.SetParent(_baseCam.transform);
|
||||
go.transform.localPosition = Vector3.zero;
|
||||
go.transform.localRotation = Quaternion.identity;
|
||||
|
||||
_handsCam = go.AddComponent<Camera>();
|
||||
_handsCam.CopyFrom(_baseCam);
|
||||
_handsCam.clearFlags = CameraClearFlags.Depth;
|
||||
_handsCam.cullingMask = _handsLayer;
|
||||
|
||||
var overlayData = go.AddComponent<UniversalAdditionalCameraData>();
|
||||
overlayData.renderType = CameraRenderType.Overlay;
|
||||
overlayData.renderPostProcessing = false;
|
||||
|
||||
baseData.cameraStack.Add(_handsCam);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!_fading) return;
|
||||
|
||||
_timer += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(_timer / Mathf.Max(0.0001f, _fadeDuration));
|
||||
float cv = _fadeCurve.Evaluate(t);
|
||||
|
||||
_current = Mathf.Lerp(_start, _target, cv);
|
||||
|
||||
ApplyEffects(_current);
|
||||
|
||||
if (t >= 1f)
|
||||
{
|
||||
_current = _target;
|
||||
ApplyEffects(_current);
|
||||
_fading = false;
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyEffects(float s)
|
||||
{
|
||||
s = Mathf.Clamp01(s);
|
||||
bool active = s > 0.01f;
|
||||
|
||||
_globalVolume.weight = s;
|
||||
|
||||
if (active)
|
||||
{
|
||||
_dof.active = true;
|
||||
_dof.gaussianStart.value = _gaussianStart;
|
||||
_dof.gaussianEnd.value = _gaussianEnd;
|
||||
float radius = Mathf.Lerp(0f, _maxGaussianRadius, s);
|
||||
_dof.gaussianMaxRadius.value = radius;
|
||||
|
||||
if (_color != null)
|
||||
{
|
||||
_color.active = true;
|
||||
_color.postExposure.value = Mathf.Lerp(_startPostExposure, _targetPostExposure, s);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_dof.gaussianMaxRadius.value = 0f;
|
||||
_dof.active = false;
|
||||
if (_color != null)
|
||||
{
|
||||
_color.postExposure.value = _startPostExposure;
|
||||
_color.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowBlur()
|
||||
{
|
||||
if (_dialogueCam == null && _autoCreateDialogueCamera)
|
||||
{
|
||||
if (_baseCam == null && PlayerController.Instance != null)
|
||||
_baseCam = PlayerController.Instance.PlayerCamera;
|
||||
|
||||
if (_baseCam != null)
|
||||
{
|
||||
SetupDialogueOverlayCamera();
|
||||
if (_autoCreateHandsCamera)
|
||||
SetupHandsOverlayCamera();
|
||||
}
|
||||
else
|
||||
Debug.LogWarning("[DialogueBlur] WARNING: Cannot create dialogue camera - PlayerCamera not ready!");
|
||||
}
|
||||
|
||||
if (_fading && _target == 1f) return;
|
||||
|
||||
_start = _current;
|
||||
_target = 1f;
|
||||
_timer = 0f;
|
||||
_fading = true;
|
||||
}
|
||||
|
||||
public void HideBlur()
|
||||
{
|
||||
if (_fading && _target == 0f) return;
|
||||
|
||||
_start = _current;
|
||||
_target = 0f;
|
||||
_timer = 0f;
|
||||
_fading = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ed4b22b4be6bbe49a67cbe552c633e1
|
||||
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
public class DialogueScreenUI : Singleton<DialogueScreenUI>
|
||||
{
|
||||
|
||||
[Header("UI References")]
|
||||
[SerializeField] private GameObject _dialoguePanel;
|
||||
[SerializeField] private TextMeshProUGUI _speakerText;
|
||||
[SerializeField] private TextMeshProUGUI _dialogueText;
|
||||
|
||||
[Header("Fade Settings")]
|
||||
[SerializeField] private float _fadeOutDuration = 0.5f;
|
||||
|
||||
private CanvasGroup _canvasGroup;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
|
||||
if (_dialoguePanel != null)
|
||||
{
|
||||
_canvasGroup = _dialoguePanel.GetComponent<CanvasGroup>();
|
||||
|
||||
if (_canvasGroup == null)
|
||||
_canvasGroup = _dialoguePanel.AddComponent<CanvasGroup>();
|
||||
}
|
||||
}
|
||||
|
||||
public void Show(string speaker, string dialogue)
|
||||
{
|
||||
if (_speakerText != null)
|
||||
_speakerText.text = speaker;
|
||||
|
||||
if (_dialogueText != null)
|
||||
_dialogueText.text = dialogue;
|
||||
|
||||
if (_dialoguePanel != null)
|
||||
{
|
||||
_dialoguePanel.SetActive(true);
|
||||
|
||||
if (_canvasGroup != null)
|
||||
{
|
||||
LeanTween.cancel(_dialoguePanel);
|
||||
_canvasGroup.alpha = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (_dialoguePanel != null)
|
||||
_dialoguePanel.SetActive(false);
|
||||
}
|
||||
|
||||
public void FadeOutAndHide(System.Action onComplete = null)
|
||||
{
|
||||
if (_canvasGroup != null && _dialoguePanel != null)
|
||||
{
|
||||
TweenHelper.FadeCanvasGroup(
|
||||
_canvasGroup,
|
||||
0f,
|
||||
_fadeOutDuration,
|
||||
() => {
|
||||
Hide();
|
||||
onComplete.SafeInvoke();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide();
|
||||
onComplete.SafeInvoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc4c44e24ba19844b822608a46bde428
|
||||
@@ -0,0 +1,393 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages dialogue UI display and choice button spawning with configurable animations.
|
||||
/// Supports multiple spawn modes, distance scaling, player following, and visual effects.
|
||||
/// </summary>
|
||||
public class DialogueUI : MonoBehaviour
|
||||
{
|
||||
[Header("Panels - World Mode")]
|
||||
[SerializeField] private GameObject _dialoguePanel;
|
||||
public GameObject ChoiceContainer;
|
||||
|
||||
[Header("Text Fields - World Mode")]
|
||||
[SerializeField] private TextMeshProUGUI _speakerText;
|
||||
[SerializeField] private TextMeshProUGUI _contentText;
|
||||
|
||||
[Header("Prefabs")]
|
||||
public ChoiceFocusHandler ChoiceButtonPrefab;
|
||||
|
||||
[Header("Default Animation Settings")]
|
||||
[SerializeField] private float _defaultRotationSpeed = 36f;
|
||||
|
||||
[Header("Fade Settings")]
|
||||
[SerializeField] private float _fadeOutDuration = 0.5f;
|
||||
[SerializeField] private CanvasGroup _dialogueCanvasGroup;
|
||||
|
||||
private DialogueAnimationSettings _currentAnimationSettings;
|
||||
private Vector3 _currentRotationAxis = Vector3.forward;
|
||||
private bool _shouldFollowPlayerPosition = false;
|
||||
private bool _shouldFollowPlayerRotation = false;
|
||||
private Transform _currentAnchor;
|
||||
private Vector3 _baseScale;
|
||||
private Vector3 _basePanelScale;
|
||||
private List<ChoiceFocusHandler> _activeChoices = new List<ChoiceFocusHandler>();
|
||||
private float _sortTimer = 0f;
|
||||
private const float SORT_INTERVAL = 0.1f;
|
||||
|
||||
private DialogueDisplayMode _currentDisplayMode = DialogueDisplayMode.World;
|
||||
|
||||
private int _fadeCanvasTweenId = -1;
|
||||
private int _rotationTweenId = -1;
|
||||
private List<int> _floatingTweenIds = new List<int>();
|
||||
|
||||
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_dialogueCanvasGroup == null && _dialoguePanel != null)
|
||||
{
|
||||
_dialogueCanvasGroup = _dialoguePanel.GetComponent<CanvasGroup>();
|
||||
|
||||
if (_dialogueCanvasGroup == null)
|
||||
_dialogueCanvasGroup = _dialoguePanel.AddComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
if (_dialoguePanel != null)
|
||||
{
|
||||
_basePanelScale = _dialoguePanel.transform.localScale;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (PlayerController.Instance == null || !ChoiceContainer.activeInHierarchy)
|
||||
return;
|
||||
|
||||
if (_shouldFollowPlayerPosition)
|
||||
ChoiceContainer.transform.position = PlayerController.Instance.transform.position;
|
||||
|
||||
if (_shouldFollowPlayerRotation)
|
||||
{
|
||||
float playerYRotation = PlayerController.Instance.transform.eulerAngles.y;
|
||||
ChoiceContainer.transform.rotation = Quaternion.Euler(0f, playerYRotation, 0f);
|
||||
}
|
||||
|
||||
if (_currentAnimationSettings != null && _currentAnimationSettings.EnableDistanceScaling && !_shouldFollowPlayerPosition)
|
||||
{
|
||||
ApplyDistanceScaling();
|
||||
ApplyDistanceScalingToPanel();
|
||||
}
|
||||
|
||||
SortChoicesByDistance();
|
||||
}
|
||||
|
||||
public void ShowDialogueBox(string speaker, string content, DialogueDisplayMode displayMode = DialogueDisplayMode.World)
|
||||
{
|
||||
_currentDisplayMode = displayMode;
|
||||
|
||||
if (displayMode == DialogueDisplayMode.World)
|
||||
{
|
||||
if (DialogueScreenUI.Instance != null)
|
||||
DialogueScreenUI.Instance.Hide();
|
||||
|
||||
_speakerText.text = speaker;
|
||||
_contentText.text = content;
|
||||
_dialoguePanel.SetActive(true);
|
||||
|
||||
if (_dialogueCanvasGroup != null)
|
||||
{
|
||||
TweenHelper.CancelTween(ref _fadeCanvasTweenId);
|
||||
_dialogueCanvasGroup.alpha = 1f;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_dialoguePanel != null)
|
||||
_dialoguePanel.SetActive(false);
|
||||
|
||||
if (DialogueScreenUI.Instance != null)
|
||||
DialogueScreenUI.Instance.Show(speaker, content);
|
||||
else
|
||||
Debug.LogWarning("[DialogueUI] DialogueScreenUI.Instance is null! Make sure DialogueScreenUI component exists in the scene.");
|
||||
}
|
||||
}
|
||||
|
||||
public void HideDialogueBox()
|
||||
{
|
||||
if (_dialoguePanel != null)
|
||||
_dialoguePanel.SetActive(false);
|
||||
|
||||
if (DialogueScreenUI.Instance != null)
|
||||
DialogueScreenUI.Instance.Hide();
|
||||
|
||||
ChoiceContainer.SetActive(false);
|
||||
HideChoices();
|
||||
}
|
||||
|
||||
public void FadeOutAndHide(System.Action onComplete = null)
|
||||
{
|
||||
if (_currentDisplayMode == DialogueDisplayMode.World)
|
||||
{
|
||||
if (_dialogueCanvasGroup != null && _dialoguePanel != null)
|
||||
{
|
||||
TweenHelper.CancelTween(ref _fadeCanvasTweenId);
|
||||
_fadeCanvasTweenId = TweenHelper.FadeCanvasGroup(
|
||||
_dialogueCanvasGroup,
|
||||
0f,
|
||||
_fadeOutDuration,
|
||||
() =>
|
||||
{
|
||||
HideDialogueBox();
|
||||
onComplete?.Invoke();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
HideDialogueBox();
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (DialogueScreenUI.Instance != null)
|
||||
{
|
||||
DialogueScreenUI.Instance.FadeOutAndHide(() =>
|
||||
{
|
||||
HideChoices();
|
||||
ChoiceContainer.SetActive(false);
|
||||
onComplete?.Invoke();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
HideDialogueBox();
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool AreChoicesVisible()
|
||||
{
|
||||
return ChoiceContainer.transform.childCount > 0;
|
||||
}
|
||||
|
||||
public void ShowChoices(List<DialogueChoiceData> nodeChoices, Transform anchorTransform, DialogueAnimationSettings animationSettings = null)
|
||||
{
|
||||
HideChoices();
|
||||
|
||||
_currentAnimationSettings = animationSettings;
|
||||
_currentAnchor = anchorTransform;
|
||||
|
||||
if (anchorTransform != null)
|
||||
{
|
||||
ChoiceContainer.transform.SetParent(anchorTransform);
|
||||
|
||||
bool followPosition = animationSettings != null && animationSettings.FollowPlayerPosition;
|
||||
bool followRotation = animationSettings != null && animationSettings.FollowPlayerRotation;
|
||||
|
||||
_shouldFollowPlayerPosition = followPosition;
|
||||
_shouldFollowPlayerRotation = followRotation;
|
||||
|
||||
if (followPosition && PlayerController.Instance != null)
|
||||
{
|
||||
Vector3 playerWorldPos = PlayerController.Instance.transform.position;
|
||||
ChoiceContainer.transform.position = playerWorldPos;
|
||||
}
|
||||
else
|
||||
ChoiceContainer.transform.localPosition = Vector3.zero;
|
||||
|
||||
if (followRotation && PlayerController.Instance != null)
|
||||
{
|
||||
float playerYRotation = PlayerController.Instance.transform.eulerAngles.y;
|
||||
ChoiceContainer.transform.rotation = Quaternion.Euler(0f, playerYRotation, 0f);
|
||||
}
|
||||
else
|
||||
ChoiceContainer.transform.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
ChoiceContainer.SetActive(true);
|
||||
ChoiceContainer.transform.localScale = Vector3.one;
|
||||
_baseScale = Vector3.one;
|
||||
|
||||
int numChoices = nodeChoices.Count;
|
||||
if (numChoices == 0) return;
|
||||
|
||||
SpawnChoices(nodeChoices, animationSettings);
|
||||
|
||||
if (animationSettings != null && animationSettings.EnableDistanceScaling && !_shouldFollowPlayerPosition)
|
||||
{
|
||||
ApplyDistanceScaling();
|
||||
ApplyDistanceScalingToPanel();
|
||||
}
|
||||
|
||||
bool shouldRotate = animationSettings == null || animationSettings.EnableRotation;
|
||||
if (shouldRotate)
|
||||
{
|
||||
float rotationSpeed = animationSettings != null ? animationSettings.NormalRotationSpeed : _defaultRotationSpeed;
|
||||
RotationAnimation(rotationSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnChoices(List<DialogueChoiceData> nodeChoices, DialogueAnimationSettings settings)
|
||||
{
|
||||
_currentRotationAxis = GetRotationAxisVector(settings != null ? settings.RotationAxis : RotationAxis.Forward);
|
||||
|
||||
if (settings == null) return;
|
||||
|
||||
Vector3[] originalPositions;
|
||||
List<ChoiceFocusHandler> spawnedChoices = FloatingWordsSpawner.SpawnByMode(
|
||||
nodeChoices,
|
||||
ChoiceButtonPrefab,
|
||||
ChoiceContainer.transform,
|
||||
settings,
|
||||
out originalPositions
|
||||
);
|
||||
|
||||
for (int i = 0; i < spawnedChoices.Count; i++)
|
||||
{
|
||||
_activeChoices.Add(spawnedChoices[i]);
|
||||
|
||||
if (settings.EnableFloating)
|
||||
StartFloatingAnimation(spawnedChoices[i].gameObject, i);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartFloatingAnimation(GameObject choice, int index)
|
||||
{
|
||||
if (_currentAnimationSettings == null) return;
|
||||
|
||||
float amplitude = _currentAnimationSettings.FloatingAmplitude;
|
||||
float duration = _currentAnimationSettings.FloatingDuration;
|
||||
float delay = index * 0.2f;
|
||||
|
||||
Vector3 startPos = choice.transform.localPosition;
|
||||
|
||||
int tweenId = TweenHelper.FloatAnimation(choice, startPos, amplitude, duration, delay, Axis.Y);
|
||||
_floatingTweenIds.Add(tweenId);
|
||||
}
|
||||
|
||||
public void RotationAnimation(float degreesPerSecond = 60f)
|
||||
{
|
||||
TweenHelper.CancelTween(ref _rotationTweenId);
|
||||
_rotationTweenId = TweenHelper.RotateAround(ChoiceContainer, _currentRotationAxis, degreesPerSecond);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dynamically scales the choice container based on distance from player to anchor.
|
||||
/// Provides visual feedback for spatial awareness in 3D dialogue interactions.
|
||||
/// </summary>
|
||||
private void ApplyDistanceScaling()
|
||||
{
|
||||
if (_currentAnchor == null || PlayerController.Instance == null)
|
||||
return;
|
||||
|
||||
float distance = Vector3.Distance(_currentAnchor.position, PlayerController.Instance.transform.position);
|
||||
|
||||
float normalizedDistance = Mathf.InverseLerp(_currentAnimationSettings.MinDistance, _currentAnimationSettings.MaxDistance, distance);
|
||||
|
||||
float finalScale = Mathf.Lerp(_currentAnimationSettings.MinScaleMultiplier, _currentAnimationSettings.MaxScaleMultiplier, normalizedDistance);
|
||||
|
||||
ChoiceContainer.transform.localScale = _baseScale * finalScale;
|
||||
}
|
||||
|
||||
private void ApplyDistanceScalingToPanel()
|
||||
{
|
||||
if (_currentAnchor == null || PlayerController.Instance == null || _dialoguePanel == null)
|
||||
return;
|
||||
|
||||
float distance = Vector3.Distance(_currentAnchor.position, PlayerController.Instance.transform.position);
|
||||
|
||||
float normalizedDistance = Mathf.InverseLerp(_currentAnimationSettings.MinDistance, _currentAnimationSettings.MaxDistance, distance);
|
||||
|
||||
float finalScale = Mathf.Lerp(_currentAnimationSettings.MinScaleMultiplier, _currentAnimationSettings.MaxScaleMultiplier, normalizedDistance);
|
||||
|
||||
_dialoguePanel.transform.localScale = _basePanelScale * finalScale;
|
||||
}
|
||||
|
||||
private Vector3 GetRotationAxisVector(RotationAxis axis)
|
||||
{
|
||||
switch (axis)
|
||||
{
|
||||
case RotationAxis.Forward:
|
||||
return Vector3.forward;
|
||||
case RotationAxis.Up:
|
||||
return Vector3.up;
|
||||
case RotationAxis.Right:
|
||||
return Vector3.right;
|
||||
default:
|
||||
return Vector3.forward;
|
||||
}
|
||||
}
|
||||
|
||||
public void HideChoices()
|
||||
{
|
||||
_shouldFollowPlayerPosition = false;
|
||||
_shouldFollowPlayerRotation = false;
|
||||
_currentAnchor = null;
|
||||
_currentAnimationSettings = null;
|
||||
_activeChoices.Clear();
|
||||
|
||||
TweenHelper.CancelTween(ref _rotationTweenId);
|
||||
foreach (int tweenId in _floatingTweenIds)
|
||||
{
|
||||
int id = tweenId;
|
||||
TweenHelper.CancelTween(ref id);
|
||||
}
|
||||
_floatingTweenIds.Clear();
|
||||
|
||||
foreach (Transform child in ChoiceContainer.transform)
|
||||
{
|
||||
Destroy(child.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts choice buttons by distance to player camera.
|
||||
/// Closer choices are rendered on top by being placed last in hierarchy (higher sibling index).
|
||||
/// Uses interval-based sorting to reduce performance overhead.
|
||||
/// </summary>
|
||||
private void SortChoicesByDistance()
|
||||
{
|
||||
if (_activeChoices.Count <= 1 || PlayerController.Instance == null)
|
||||
return;
|
||||
|
||||
_sortTimer += Time.deltaTime;
|
||||
if (_sortTimer < SORT_INTERVAL)
|
||||
return;
|
||||
|
||||
_sortTimer = 0f;
|
||||
|
||||
Camera mainCamera = Camera.main;
|
||||
if (mainCamera == null)
|
||||
return;
|
||||
|
||||
Vector3 cameraPosition = mainCamera.transform.position;
|
||||
|
||||
_activeChoices.Sort((a, b) =>
|
||||
{
|
||||
if (a == null || b == null)
|
||||
return 0;
|
||||
|
||||
float distanceA = Vector3.Distance(cameraPosition, a.transform.position);
|
||||
float distanceB = Vector3.Distance(cameraPosition, b.transform.position);
|
||||
|
||||
return distanceB.CompareTo(distanceA);
|
||||
});
|
||||
|
||||
for (int i = 0; i < _activeChoices.Count; i++)
|
||||
{
|
||||
if (_activeChoices[i] != null)
|
||||
_activeChoices[i].transform.SetSiblingIndex(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90f07eb59fe4c5b49b180cceacb3f24c
|
||||
@@ -0,0 +1,405 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Features.Dialogue.Data;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
public static class FloatingWordsSpawner
|
||||
{
|
||||
private const float ROTATOR_SCALE = 0.01f;
|
||||
|
||||
/// <summary>
|
||||
/// Internal configuration struct to unify settings from different sources.
|
||||
/// </summary>
|
||||
public struct SpawnConfig
|
||||
{
|
||||
public float SpawnRadius;
|
||||
public float SpawnHeightOffset;
|
||||
public RotationAxis RotationAxis;
|
||||
public bool EnableOrganicShake;
|
||||
public float ForwardOffset;
|
||||
public float TiltAngle;
|
||||
public float RotationSpeed;
|
||||
public bool EnableRotation;
|
||||
public ChoiceSpawnMode SpawnMode;
|
||||
public bool EnableFloating;
|
||||
public float MergeDuration;
|
||||
public float MergeScalePulse;
|
||||
|
||||
public bool EnableGhostTrail;
|
||||
public float GhostTrailAlpha;
|
||||
public float GhostTrailDelay;
|
||||
public float ShakeRadius;
|
||||
public float ShakeSpeed;
|
||||
public float ShakeSmoothness;
|
||||
public bool MoveTowardPlayerOnFocus;
|
||||
public float FocusDistanceFromPlayer;
|
||||
public bool DisableFloatingEffects;
|
||||
public float DefaultScaleMultiplier;
|
||||
public bool EnableScaleVariation;
|
||||
public float MinScaleVariation;
|
||||
public float MaxScaleVariation;
|
||||
}
|
||||
|
||||
private static SpawnConfig GetConfig(ObjectDescriptionSettings settings)
|
||||
{
|
||||
return new SpawnConfig
|
||||
{
|
||||
SpawnRadius = settings.SpawnRadius,
|
||||
SpawnHeightOffset = settings.SpawnHeightOffset,
|
||||
RotationAxis = settings.RotationAxis,
|
||||
EnableOrganicShake = settings.EnableOrganicShake,
|
||||
ForwardOffset = settings.ForwardOffset,
|
||||
TiltAngle = settings.TiltAngle,
|
||||
RotationSpeed = settings.RotationSpeed,
|
||||
EnableRotation = settings.EnableRotation,
|
||||
SpawnMode = settings.SpawnMode,
|
||||
EnableFloating = settings.EnableFloating,
|
||||
MergeDuration = settings.MergeDuration,
|
||||
MergeScalePulse = settings.MergeScalePulse,
|
||||
|
||||
EnableGhostTrail = settings.EnableGhostTrail,
|
||||
GhostTrailAlpha = settings.GhostTrailAlpha,
|
||||
GhostTrailDelay = settings.GhostTrailDelay,
|
||||
ShakeRadius = settings.ShakeRadius,
|
||||
ShakeSpeed = settings.ShakeSpeed,
|
||||
ShakeSmoothness = settings.ShakeSmoothness,
|
||||
MoveTowardPlayerOnFocus = false,
|
||||
FocusDistanceFromPlayer = 1f,
|
||||
DisableFloatingEffects = true,
|
||||
DefaultScaleMultiplier = 1f
|
||||
};
|
||||
}
|
||||
|
||||
private static SpawnConfig GetConfig(DialogueAnimationSettings settings)
|
||||
{
|
||||
return new SpawnConfig
|
||||
{
|
||||
SpawnRadius = settings.SpawnRadius,
|
||||
SpawnHeightOffset = settings.SpawnHeightOffset,
|
||||
RotationAxis = settings.RotationAxis,
|
||||
EnableOrganicShake = settings.EnableOrganicShake,
|
||||
ForwardOffset = 0f,
|
||||
TiltAngle = 0f,
|
||||
RotationSpeed = settings.NormalRotationSpeed,
|
||||
EnableRotation = settings.EnableRotation,
|
||||
SpawnMode = settings.SpawnMode,
|
||||
EnableFloating = settings.EnableFloating,
|
||||
MergeDuration = 0.3f,
|
||||
MergeScalePulse = 1.2f,
|
||||
|
||||
EnableGhostTrail = settings.EnableGhostTrail,
|
||||
GhostTrailAlpha = settings.GhostTrailAlpha,
|
||||
GhostTrailDelay = settings.GhostTrailDelay,
|
||||
ShakeRadius = settings.ShakeRadius,
|
||||
ShakeSpeed = settings.ShakeSpeed,
|
||||
ShakeSmoothness = settings.ShakeSmoothness,
|
||||
MoveTowardPlayerOnFocus = settings.MoveTowardPlayerOnFocus,
|
||||
FocusDistanceFromPlayer = settings.FocusDistanceFromPlayer,
|
||||
DisableFloatingEffects = false,
|
||||
DefaultScaleMultiplier = settings.DefaultScaleMultiplier,
|
||||
EnableScaleVariation = settings.EnableScaleVariation,
|
||||
MinScaleVariation = settings.MinScaleVariation,
|
||||
MaxScaleVariation = settings.MaxScaleVariation
|
||||
};
|
||||
}
|
||||
|
||||
public static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> nodeChoices, ChoiceFocusHandler prefab, Transform container, DialogueAnimationSettings settings, out Vector3[] originalPositions)
|
||||
{
|
||||
return SpawnByMode(nodeChoices, prefab, container, GetConfig(settings), out originalPositions);
|
||||
}
|
||||
|
||||
public static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform container, ObjectDescriptionSettings settings, out Vector3[] originalPositions)
|
||||
{
|
||||
return SpawnByMode(choices, prefab, container, GetConfig(settings), out originalPositions);
|
||||
}
|
||||
|
||||
private static List<ChoiceFocusHandler> SpawnByMode(List<DialogueChoiceData> nodeChoices, ChoiceFocusHandler prefab, Transform container, SpawnConfig config, out Vector3[] originalPositions)
|
||||
{
|
||||
switch (config.SpawnMode)
|
||||
{
|
||||
case ChoiceSpawnMode.AroundAnchor:
|
||||
return SpawnWordsInCircle(nodeChoices, prefab, container, config, out originalPositions);
|
||||
case ChoiceSpawnMode.AtAnchorPosition:
|
||||
originalPositions = new Vector3[0];
|
||||
return SpawnAtAnchor(nodeChoices, prefab, container, config);
|
||||
case ChoiceSpawnMode.InFrontOfPlayer:
|
||||
originalPositions = new Vector3[0];
|
||||
return SpawnInFrontOfPlayer(nodeChoices, prefab, container, config);
|
||||
case ChoiceSpawnMode.CircleAroundPlayer:
|
||||
return SpawnAroundPlayer(nodeChoices, prefab, container, config, out originalPositions);
|
||||
default:
|
||||
return SpawnWordsInCircle(nodeChoices, prefab, container, config, out originalPositions);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ChoiceFocusHandler> SpawnWordsInCircle(List<DialogueChoiceData> choices, ChoiceFocusHandler wordPrefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions)
|
||||
{
|
||||
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
|
||||
originalPositions = new Vector3[choices.Count];
|
||||
float angleStep = 360f / choices.Count;
|
||||
|
||||
for (int i = 0; i < choices.Count; i++)
|
||||
{
|
||||
float angle = i * angleStep * Mathf.Deg2Rad;
|
||||
Vector3 localOffset = CalculateCircularPosition(angle, config.SpawnRadius, config.RotationAxis, config.SpawnHeightOffset);
|
||||
originalPositions[i] = localOffset;
|
||||
|
||||
ChoiceFocusHandler word = Object.Instantiate(wordPrefab, parent);
|
||||
word.transform.localPosition = localOffset;
|
||||
word.transform.localRotation = Quaternion.identity;
|
||||
|
||||
float scaleMultiplier = config.DefaultScaleMultiplier;
|
||||
if (config.EnableScaleVariation)
|
||||
{
|
||||
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
|
||||
}
|
||||
word.transform.localScale = Vector3.one * scaleMultiplier;
|
||||
|
||||
CanvasGroup canvasGroup = word.GetComponent<CanvasGroup>();
|
||||
if (canvasGroup == null)
|
||||
canvasGroup = word.gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
canvasGroup.alpha = 1f;
|
||||
|
||||
word.Setup(choices[i], config, config.DisableFloatingEffects);
|
||||
|
||||
if (config.EnableOrganicShake) word.StartOrganicShake();
|
||||
|
||||
words.Add(word);
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
private static List<ChoiceFocusHandler> SpawnAtAnchor(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config)
|
||||
{
|
||||
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
|
||||
for (int i = 0; i < choices.Count; i++)
|
||||
{
|
||||
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
|
||||
word.transform.localPosition = Vector3.up * i * 50f;
|
||||
word.transform.localRotation = Quaternion.identity;
|
||||
|
||||
float scaleMultiplier = config.DefaultScaleMultiplier;
|
||||
if (config.EnableScaleVariation)
|
||||
{
|
||||
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
|
||||
}
|
||||
word.transform.localScale = Vector3.one * scaleMultiplier;
|
||||
word.Setup(choices[i], config);
|
||||
words.Add(word);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
private static List<ChoiceFocusHandler> SpawnInFrontOfPlayer(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config)
|
||||
{
|
||||
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
|
||||
if (PlayerController.Instance == null) return words;
|
||||
|
||||
Vector3 playerForward = PlayerController.Instance.transform.forward;
|
||||
for (int i = 0; i < choices.Count; i++)
|
||||
{
|
||||
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
|
||||
Vector3 offset = playerForward * 2f + Vector3.up * (i - choices.Count / 2f) * (config.SpawnRadius / choices.Count / 100f);
|
||||
word.transform.position = PlayerController.Instance.transform.position + offset;
|
||||
word.transform.LookAt(PlayerController.Instance.transform);
|
||||
|
||||
float scaleMultiplier = config.DefaultScaleMultiplier;
|
||||
if (config.EnableScaleVariation)
|
||||
{
|
||||
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
|
||||
}
|
||||
word.transform.localScale = Vector3.one * scaleMultiplier;
|
||||
word.Setup(choices[i], config);
|
||||
words.Add(word);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
private static List<ChoiceFocusHandler> SpawnAroundPlayer(List<DialogueChoiceData> choices, ChoiceFocusHandler prefab, Transform parent, SpawnConfig config, out Vector3[] originalPositions)
|
||||
{
|
||||
List<ChoiceFocusHandler> words = new List<ChoiceFocusHandler>();
|
||||
originalPositions = new Vector3[choices.Count];
|
||||
if (PlayerController.Instance == null) return words;
|
||||
|
||||
parent.position = PlayerController.Instance.transform.position;
|
||||
parent.rotation = Quaternion.identity;
|
||||
|
||||
float angleStep = 360f / choices.Count;
|
||||
for (int i = 0; i < choices.Count; i++)
|
||||
{
|
||||
float angle = i * angleStep * Mathf.Deg2Rad;
|
||||
Vector3 localOffset = new Vector3(Mathf.Cos(angle) * config.SpawnRadius, config.SpawnHeightOffset, Mathf.Sin(angle) * config.SpawnRadius);
|
||||
originalPositions[i] = localOffset;
|
||||
|
||||
ChoiceFocusHandler word = Object.Instantiate(prefab, parent);
|
||||
word.transform.localPosition = localOffset;
|
||||
word.transform.LookAt(PlayerController.Instance.transform);
|
||||
|
||||
float scaleMultiplier = config.DefaultScaleMultiplier;
|
||||
if (config.EnableScaleVariation)
|
||||
{
|
||||
scaleMultiplier *= Random.Range(config.MinScaleVariation, config.MaxScaleVariation);
|
||||
}
|
||||
word.transform.localScale = Vector3.one * scaleMultiplier;
|
||||
|
||||
word.Setup(choices[i], config);
|
||||
words.Add(word);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
public static void MergeWords(List<ChoiceFocusHandler> words, ObjectDescriptionSettings settings, System.Action onMergeComplete = null)
|
||||
{
|
||||
MergeWords(words, GetConfig(settings), onMergeComplete);
|
||||
}
|
||||
|
||||
private static void MergeWords(List<ChoiceFocusHandler> words, SpawnConfig config, System.Action onMergeComplete)
|
||||
{
|
||||
for (int i = 0; i < words.Count; i++)
|
||||
{
|
||||
if (words[i] != null)
|
||||
{
|
||||
words[i].StopOrganicShake();
|
||||
words[i].HideGhostTrail();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i < words.Count; i++)
|
||||
{
|
||||
if (words[i] != null)
|
||||
{
|
||||
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
|
||||
if (canvasGroup != null)
|
||||
{
|
||||
TweenHelper.FadeCanvasGroup(canvasGroup, 0f, config.MergeDuration, null, LeanTweenType.easeInQuad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
TweenHelper.ScaleLocal(
|
||||
words[0].gameObject,
|
||||
Vector3.one * config.MergeScalePulse,
|
||||
config.MergeDuration / 2f,
|
||||
() =>
|
||||
{
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
TweenHelper.ScaleLocal(
|
||||
words[0].gameObject,
|
||||
Vector3.one,
|
||||
config.MergeDuration / 2f,
|
||||
() => onMergeComplete.SafeInvoke(),
|
||||
LeanTweenType.easeInBack
|
||||
);
|
||||
}
|
||||
},
|
||||
LeanTweenType.easeOutBack
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static void UnmergeWords(List<ChoiceFocusHandler> words, ObjectDescriptionSettings settings)
|
||||
{
|
||||
UnmergeWords(words, GetConfig(settings));
|
||||
}
|
||||
|
||||
private static void UnmergeWords(List<ChoiceFocusHandler> words, SpawnConfig config)
|
||||
{
|
||||
for (int i = 0; i < words.Count; i++)
|
||||
{
|
||||
if (words[i] != null)
|
||||
{
|
||||
words[i].StartOrganicShake();
|
||||
words[i].ShowGhostTrail();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i < words.Count; i++)
|
||||
{
|
||||
if (words[i] != null)
|
||||
{
|
||||
words[i].gameObject.CancelAllTweens();
|
||||
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
|
||||
if (canvasGroup != null)
|
||||
{
|
||||
TweenHelper.FadeCanvasGroup(canvasGroup, 1f, config.MergeDuration, null, LeanTweenType.easeOutQuad);
|
||||
}
|
||||
words[i].transform.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
words[0].gameObject.CancelAllTweens();
|
||||
words[0].transform.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
|
||||
#region For obj description
|
||||
public static GameObject CreateWordsContainer(string containerName, Transform anchor, ObjectDescriptionSettings settings)
|
||||
{
|
||||
SpawnConfig config = GetConfig(settings);
|
||||
GameObject rotator = new GameObject($"{containerName}_Rotator");
|
||||
rotator.transform.localPosition = Vector3.forward * config.ForwardOffset;
|
||||
rotator.transform.localRotation = Quaternion.Euler(config.TiltAngle, 0f, 0f);
|
||||
rotator.transform.localScale = Vector3.one * ROTATOR_SCALE;
|
||||
|
||||
Canvas containerCanvas = rotator.AddComponent<Canvas>();
|
||||
containerCanvas.renderMode = RenderMode.WorldSpace;
|
||||
|
||||
return rotator;
|
||||
}
|
||||
|
||||
|
||||
public static void UpdateContainerRotation(GameObject container, GameObject rotator, Transform playerTransform, ObjectDescriptionSettings settings)
|
||||
{
|
||||
if (container == null || playerTransform == null) return;
|
||||
|
||||
Vector3 lookAtPosition = playerTransform.position;
|
||||
lookAtPosition.y = container.transform.position.y;
|
||||
container.transform.LookAt(lookAtPosition);
|
||||
|
||||
if (!settings.EnableRotation || rotator == null) return;
|
||||
|
||||
Vector3 rotationAxis = GetRotationAxisVector(settings.RotationAxis);
|
||||
float rotationAmount = (360f / settings.RotationSpeed) * Time.deltaTime;
|
||||
rotator.transform.Rotate(rotationAxis, rotationAmount, Space.Self);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Tools
|
||||
private static Vector3 CalculateCircularPosition(float angleRad, float radius, RotationAxis axis, float heightOffset)
|
||||
{
|
||||
switch (axis)
|
||||
{
|
||||
case RotationAxis.Forward:
|
||||
return new Vector3(Mathf.Cos(angleRad) * radius, Mathf.Sin(angleRad) * radius + heightOffset, 0f);
|
||||
case RotationAxis.Up:
|
||||
return new Vector3(Mathf.Cos(angleRad) * radius, heightOffset, Mathf.Sin(angleRad) * radius);
|
||||
case RotationAxis.Right:
|
||||
return new Vector3(0f, Mathf.Cos(angleRad) * radius + heightOffset, Mathf.Sin(angleRad) * radius);
|
||||
default:
|
||||
return new Vector3(Mathf.Cos(angleRad) * radius, heightOffset, Mathf.Sin(angleRad) * radius);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector3 GetRotationAxisVector(RotationAxis axis)
|
||||
{
|
||||
switch (axis)
|
||||
{
|
||||
case RotationAxis.Forward: return Vector3.forward;
|
||||
case RotationAxis.Up: return Vector3.up;
|
||||
case RotationAxis.Right: return Vector3.right;
|
||||
default: return Vector3.up;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b52b115a50463d46b645f5579cf22fd
|
||||
@@ -0,0 +1,215 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
public class SketchFadeEffect : MonoBehaviour
|
||||
{
|
||||
private TextMeshProUGUI _textComponent;
|
||||
private TextMeshProUGUI _ghostTextComponent;
|
||||
private Image _scribbleOverlay;
|
||||
private Material _scribbleMaterialInstance;
|
||||
private Material _scribbleMaterialTemplate;
|
||||
|
||||
private static readonly int ProgressID = Shader.PropertyToID("_Progress");
|
||||
|
||||
private bool _isEffectActive = false;
|
||||
private bool _overlayCreated = false;
|
||||
private Color _originalTextColor;
|
||||
private Color _originalGhostColor;
|
||||
|
||||
private int _scribbleTweenId = -1;
|
||||
private int _fadeTweenId = -1;
|
||||
|
||||
public void Initialize(TextMeshProUGUI textComponent, TextMeshProUGUI ghostComponent, Material scribbleMaterial)
|
||||
{
|
||||
_textComponent = textComponent;
|
||||
_ghostTextComponent = ghostComponent;
|
||||
_scribbleMaterialTemplate = scribbleMaterial;
|
||||
_originalTextColor = _textComponent.color;
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
_originalGhostColor = _ghostTextComponent.color;
|
||||
}
|
||||
|
||||
private void CreateScribbleOverlay()
|
||||
{
|
||||
if (_overlayCreated || _scribbleMaterialTemplate == null || _textComponent == null)
|
||||
return;
|
||||
|
||||
GameObject overlayObject = new GameObject("ScribbleOverlay");
|
||||
overlayObject.transform.SetParent(_textComponent.transform, false);
|
||||
|
||||
_scribbleOverlay = overlayObject.AddComponent<Image>();
|
||||
_scribbleOverlay.raycastTarget = false;
|
||||
|
||||
RectTransform overlayRect = overlayObject.GetComponent<RectTransform>();
|
||||
RectTransform textRect = _textComponent.GetComponent<RectTransform>();
|
||||
|
||||
_textComponent.ForceMeshUpdate();
|
||||
|
||||
Vector2 textSize = _textComponent.GetRenderedValues(false);
|
||||
|
||||
float horizontalPadding = 1.2f;
|
||||
float verticalPadding = 0.8f;
|
||||
Vector2 paddedSize = new Vector2(
|
||||
textSize.x + horizontalPadding,
|
||||
textSize.y + verticalPadding
|
||||
);
|
||||
|
||||
overlayRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
overlayRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
overlayRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
float leftOffset = -horizontalPadding * 0.9f;
|
||||
overlayRect.anchoredPosition = new Vector2(leftOffset, 0f);
|
||||
overlayRect.sizeDelta = paddedSize;
|
||||
|
||||
overlayRect.localScale = Vector3.one;
|
||||
|
||||
_scribbleMaterialInstance = new Material(_scribbleMaterialTemplate);
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, 0f);
|
||||
_scribbleOverlay.material = _scribbleMaterialInstance;
|
||||
|
||||
_scribbleOverlay.gameObject.SetActive(false);
|
||||
|
||||
_overlayCreated = true;
|
||||
}
|
||||
|
||||
public void PlaySketchFadeEffect(float scribbleDuration, float fadeDuration, float finalAlpha)
|
||||
{
|
||||
if (_isEffectActive || _textComponent == null)
|
||||
return;
|
||||
|
||||
CancelActiveTweens();
|
||||
|
||||
CreateScribbleOverlay();
|
||||
|
||||
if (_scribbleOverlay == null || _scribbleMaterialInstance == null)
|
||||
return;
|
||||
|
||||
_scribbleOverlay.gameObject.SetActive(true);
|
||||
|
||||
_isEffectActive = true;
|
||||
|
||||
_scribbleTweenId = LeanTween.value(gameObject, 0f, 1f, scribbleDuration)
|
||||
.setEase(LeanTweenType.easeInOutQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_scribbleMaterialInstance != null)
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, val);
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_scribbleTweenId = -1;
|
||||
|
||||
float ghostAlphaRatio = _ghostTextComponent != null ? (_originalGhostColor.a / _originalTextColor.a) : 0f;
|
||||
|
||||
_fadeTweenId = LeanTween.value(gameObject, _originalTextColor.a, finalAlpha, fadeDuration)
|
||||
.setEase(LeanTweenType.easeInQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_textComponent != null)
|
||||
{
|
||||
_textComponent.color = _textComponent.color.WithAlpha(val);
|
||||
}
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
{
|
||||
_ghostTextComponent.color = _ghostTextComponent.color.WithAlpha(val * ghostAlphaRatio);
|
||||
}
|
||||
|
||||
if (_scribbleOverlay != null)
|
||||
{
|
||||
_scribbleOverlay.color = _scribbleOverlay.color.WithAlpha(val);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_fadeTweenId = -1;
|
||||
_isEffectActive = false;
|
||||
}).id;
|
||||
}).id;
|
||||
}
|
||||
|
||||
public void ResetEffect()
|
||||
{
|
||||
if (!_overlayCreated || _scribbleOverlay == null)
|
||||
{
|
||||
_isEffectActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool overlayIsActive = _scribbleOverlay.gameObject.activeSelf;
|
||||
|
||||
if (!overlayIsActive)
|
||||
{
|
||||
_isEffectActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
CancelActiveTweens();
|
||||
_isEffectActive = false;
|
||||
|
||||
if (_scribbleMaterialInstance != null)
|
||||
{
|
||||
float currentProgress = _scribbleMaterialInstance.GetFloat(ProgressID);
|
||||
|
||||
_scribbleTweenId = LeanTween.value(gameObject, currentProgress, 0f, 0.3f)
|
||||
.setEase(LeanTweenType.easeOutQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_scribbleMaterialInstance != null)
|
||||
_scribbleMaterialInstance.SetFloat(ProgressID, val);
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_scribbleTweenId = -1;
|
||||
if (_scribbleOverlay != null)
|
||||
_scribbleOverlay.gameObject.SetActive(false);
|
||||
}).id;
|
||||
}
|
||||
|
||||
if (_textComponent != null)
|
||||
{
|
||||
float currentAlpha = _textComponent.color.a;
|
||||
float ghostAlphaRatio = _ghostTextComponent != null ? (_originalGhostColor.a / _originalTextColor.a) : 0f;
|
||||
|
||||
_fadeTweenId = LeanTween.value(gameObject, currentAlpha, _originalTextColor.a, 0.3f)
|
||||
.setEase(LeanTweenType.easeOutQuad)
|
||||
.setOnUpdate((float val) => {
|
||||
if (_textComponent != null)
|
||||
{
|
||||
_textComponent.color = _textComponent.color.WithAlpha(val);
|
||||
}
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
{
|
||||
_ghostTextComponent.color = _ghostTextComponent.color.WithAlpha(val * ghostAlphaRatio);
|
||||
}
|
||||
|
||||
if (_scribbleOverlay != null)
|
||||
{
|
||||
_scribbleOverlay.color = _scribbleOverlay.color.WithAlpha(val);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_fadeTweenId = -1;
|
||||
}).id;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelActiveTweens()
|
||||
{
|
||||
TweenHelper.CancelTween(ref _scribbleTweenId);
|
||||
TweenHelper.CancelTween(ref _fadeTweenId);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
CancelActiveTweens();
|
||||
|
||||
if (_scribbleMaterialInstance != null)
|
||||
Destroy(_scribbleMaterialInstance);
|
||||
|
||||
if (_scribbleOverlay != null)
|
||||
Destroy(_scribbleOverlay.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ee81e39cd832e34d9219099af39f4bb
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a26921d06d64544184c5cbcf41b0b7d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e871b828a2e9ca4ba7cc5e025373372
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Diapason
|
||||
{
|
||||
|
||||
[System.Serializable]
|
||||
public class TapAnimationSettings
|
||||
{
|
||||
[Header("Tap Motion")]
|
||||
[Tooltip("Elbow rotation during windup (degrees)")]
|
||||
public float WindupRotation = 15f;
|
||||
|
||||
[Tooltip("Duration of windup (pull back)")]
|
||||
public float WindupDuration = 0.1f;
|
||||
|
||||
[Tooltip("Duration of strike (forward snap)")]
|
||||
public float StrikeDuration = 0.05f;
|
||||
|
||||
[Tooltip("Wrist snap rotation during strike (degrees)")]
|
||||
public float WristSnapRotation = 10f;
|
||||
|
||||
[Header("Vibration Shake")]
|
||||
[Tooltip("Shake intensity during vibration")]
|
||||
public float ShakeIntensity = 2f;
|
||||
|
||||
[Tooltip("Shake speed (frequency)")]
|
||||
public float ShakeSpeed = 30f;
|
||||
}
|
||||
|
||||
public class EquippableTapSettings : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TapAnimationSettings _tapSettings = new TapAnimationSettings();
|
||||
|
||||
public TapAnimationSettings TapSettings => _tapSettings;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b46af809fa8f34d49b3dec606a505c5e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: adbe6317f6c8e9e469b36e06ab2abbfb
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,373 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Core.Audio;
|
||||
using Unheimlich.Features.OctoArms;
|
||||
using System;
|
||||
using System.Collections;
|
||||
|
||||
namespace Unheimlich.Features.Diapason
|
||||
{
|
||||
|
||||
public class DiapasonEquipmentManager : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private OctoIkArmsImproved _armsSystem;
|
||||
[SerializeField] private GameObject _diapasonPrefab;
|
||||
[SerializeField] private int _equipmentArmIndex = 1;
|
||||
|
||||
[Header("Detection Settings")]
|
||||
[SerializeField] private LayerMask _soundEmitterLayer;
|
||||
[SerializeField] private float _detectionRadius = 0.15f;
|
||||
[SerializeField] private Transform[] _detectionPoints;
|
||||
|
||||
[Header("Visual Feedback")]
|
||||
[SerializeField] private GameObject _contactFeedbackPrefab;
|
||||
|
||||
[Header("Diapason Animation")]
|
||||
[SerializeField] private string _vibratingBoolParam = "IsVibrating";
|
||||
[SerializeField] private string _tapTriggerParam = "Tap";
|
||||
|
||||
[Header("Tap Settings")]
|
||||
[SerializeField] private AudioClip _tapSound;
|
||||
[SerializeField] [Range(0f, 1f)] private float _tapVolume = 1f;
|
||||
[SerializeField] private float _vibrationDuration = 15f;
|
||||
|
||||
public bool IsEquipped => _isEquipped;
|
||||
public AudioListenable CurrentDetectedEmitter => _currentDetectedEmitter;
|
||||
public bool CanTap => _isEquipped && _currentDetectedEmitter != null && !_isVibrating && !_currentDetectedEmitter.HasBeenTapped;
|
||||
|
||||
public event Action<AudioListenable> OnEmitterDetected;
|
||||
public event Action<AudioListenable> OnEmitterLost;
|
||||
|
||||
private GameObject _spawnedDiapason;
|
||||
private bool _isEquipped = false;
|
||||
private AudioListenable _currentDetectedEmitter;
|
||||
private GameObject _contactFeedback;
|
||||
private bool _isVibrating = false;
|
||||
private AudioSource _audioSource;
|
||||
private Coroutine _vibrationCoroutine;
|
||||
private Animator _diapasonAnimator;
|
||||
private ParticleSystem _diapasonParticles;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (_armsSystem == null)
|
||||
{
|
||||
_armsSystem = GetComponent<OctoIkArmsImproved>();
|
||||
if (_armsSystem == null)
|
||||
{
|
||||
Debug.LogError("[DiapasonEquipment] OctoIkArmsImproved not found!");
|
||||
}
|
||||
}
|
||||
|
||||
_audioSource = GetComponent<AudioSource>();
|
||||
if (_audioSource == null)
|
||||
{
|
||||
_audioSource = gameObject.AddComponent<AudioSource>();
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_isEquipped && _armsSystem.GetArmState(_equipmentArmIndex) == ArmState.Equipped)
|
||||
{
|
||||
CheckForSoundEmitters();
|
||||
|
||||
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
if (CanTap)
|
||||
{
|
||||
TapDiapason();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void EquipDiapason()
|
||||
{
|
||||
if (_isEquipped)
|
||||
{
|
||||
Debug.LogWarning("[DiapasonEquipment] Diapason already equipped!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_diapasonPrefab == null)
|
||||
{
|
||||
Debug.LogError("[DiapasonEquipment] Diapason prefab not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
_spawnedDiapason = Instantiate(_diapasonPrefab);
|
||||
_spawnedDiapason.SetActive(true);
|
||||
|
||||
_diapasonAnimator = _spawnedDiapason.GetComponentInChildren<Animator>(true);
|
||||
if (_diapasonAnimator == null)
|
||||
{
|
||||
Debug.LogWarning("[DiapasonEquipment] No Animator found on spawned diapason");
|
||||
}
|
||||
else
|
||||
{
|
||||
_diapasonAnimator.enabled = false;
|
||||
}
|
||||
|
||||
_diapasonParticles = _spawnedDiapason.GetComponentInChildren<ParticleSystem>(true);
|
||||
if (_diapasonParticles == null)
|
||||
{
|
||||
Debug.LogWarning("[DiapasonEquipment] No ParticleSystem found on spawned diapason");
|
||||
}
|
||||
|
||||
_armsSystem.EquipObjectOnArm(_equipmentArmIndex, _spawnedDiapason, () => {
|
||||
_isEquipped = true;
|
||||
OnDiapasonEquipped();
|
||||
});
|
||||
}
|
||||
|
||||
public void UnequipDiapason()
|
||||
{
|
||||
if (!_isEquipped)
|
||||
{
|
||||
Debug.LogWarning("[DiapasonEquipment] Diapason not equipped!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vibrationCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_vibrationCoroutine);
|
||||
_vibrationCoroutine = null;
|
||||
}
|
||||
|
||||
if (_currentDetectedEmitter != null)
|
||||
{
|
||||
_currentDetectedEmitter.SetConcentrating(false);
|
||||
_currentDetectedEmitter.StopListening(cancelConcentrationFade: false);
|
||||
_currentDetectedEmitter = null;
|
||||
}
|
||||
|
||||
_armsSystem.UnequipObjectFromArm(_equipmentArmIndex, () => {
|
||||
_isEquipped = false;
|
||||
_spawnedDiapason = null;
|
||||
OnDiapasonUnequipped();
|
||||
});
|
||||
}
|
||||
|
||||
private void CheckForSoundEmitters()
|
||||
{
|
||||
if (_detectionPoints == null || _detectionPoints.Length == 0)
|
||||
return;
|
||||
|
||||
AudioListenable closestEmitter = null;
|
||||
float closestDistance = float.MaxValue;
|
||||
|
||||
foreach (Transform detectionPoint in _detectionPoints)
|
||||
{
|
||||
if (detectionPoint == null)
|
||||
continue;
|
||||
|
||||
Collider[] hits = Physics.OverlapSphere(detectionPoint.position, _detectionRadius, _soundEmitterLayer);
|
||||
|
||||
foreach (Collider hit in hits)
|
||||
{
|
||||
AudioListenable emitter = hit.GetComponent<AudioListenable>();
|
||||
|
||||
if (emitter != null)
|
||||
{
|
||||
float distance = Vector3.Distance(detectionPoint.position, hit.transform.position);
|
||||
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
closestEmitter = emitter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closestEmitter != _currentDetectedEmitter)
|
||||
{
|
||||
if (_currentDetectedEmitter != null)
|
||||
{
|
||||
OnEmitterExited(_currentDetectedEmitter);
|
||||
}
|
||||
|
||||
_currentDetectedEmitter = closestEmitter;
|
||||
|
||||
if (_currentDetectedEmitter != null)
|
||||
{
|
||||
OnEmitterEntered(_currentDetectedEmitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDiapasonEquipped()
|
||||
{
|
||||
if (_diapasonAnimator != null)
|
||||
{
|
||||
_diapasonAnimator.enabled = true;
|
||||
_diapasonAnimator.SetBool(_vibratingBoolParam, false);
|
||||
}
|
||||
|
||||
if (_diapasonParticles != null)
|
||||
{
|
||||
_diapasonParticles.Stop();
|
||||
var emission = _diapasonParticles.emission;
|
||||
emission.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDiapasonUnequipped()
|
||||
{
|
||||
_isVibrating = false;
|
||||
_diapasonAnimator = null;
|
||||
_diapasonParticles = null;
|
||||
}
|
||||
|
||||
public void TapDiapason()
|
||||
{
|
||||
if (!CanTap)
|
||||
{
|
||||
Debug.LogWarning("[DiapasonEquipment] Cannot tap: not equipped, no contact, or already tapped");
|
||||
return;
|
||||
}
|
||||
|
||||
TapAnimationSettings tapSettings = null;
|
||||
if (_spawnedDiapason != null)
|
||||
{
|
||||
EquippableTapSettings equippable = _spawnedDiapason.GetComponent<EquippableTapSettings>();
|
||||
if (equippable != null)
|
||||
{
|
||||
tapSettings = equippable.TapSettings;
|
||||
}
|
||||
}
|
||||
|
||||
ArmEquipment equipment = _armsSystem.GetArmEquipment(_equipmentArmIndex);
|
||||
if (equipment != null && tapSettings != null)
|
||||
{
|
||||
equipment.PlayTapAnimation(tapSettings);
|
||||
equipment.StartShake(tapSettings);
|
||||
}
|
||||
|
||||
if (_tapSound != null && _audioSource != null)
|
||||
{
|
||||
_audioSource.PlayOneShot(_tapSound, _tapVolume);
|
||||
}
|
||||
|
||||
if (_diapasonAnimator != null)
|
||||
{
|
||||
_diapasonAnimator.SetTrigger(_tapTriggerParam);
|
||||
_diapasonAnimator.SetBool(_vibratingBoolParam, true);
|
||||
}
|
||||
|
||||
if (_diapasonParticles != null)
|
||||
{
|
||||
var emission = _diapasonParticles.emission;
|
||||
emission.enabled = true;
|
||||
_diapasonParticles.Play();
|
||||
}
|
||||
|
||||
_isVibrating = true;
|
||||
|
||||
if (_currentDetectedEmitter != null)
|
||||
{
|
||||
_currentDetectedEmitter.StartListening(transform.position);
|
||||
}
|
||||
|
||||
if (_vibrationCoroutine != null)
|
||||
StopCoroutine(_vibrationCoroutine);
|
||||
_vibrationCoroutine = StartCoroutine(StopVibrationAfterDuration());
|
||||
}
|
||||
|
||||
private IEnumerator StopVibrationAfterDuration()
|
||||
{
|
||||
yield return new WaitForSeconds(_vibrationDuration);
|
||||
|
||||
if (_diapasonAnimator != null)
|
||||
{
|
||||
_diapasonAnimator.SetBool(_vibratingBoolParam, false);
|
||||
}
|
||||
|
||||
if (_diapasonParticles != null)
|
||||
{
|
||||
_diapasonParticles.Stop();
|
||||
var emission = _diapasonParticles.emission;
|
||||
emission.enabled = false;
|
||||
}
|
||||
|
||||
if (_currentDetectedEmitter != null)
|
||||
{
|
||||
_currentDetectedEmitter.StopListening();
|
||||
}
|
||||
|
||||
ArmEquipment equipment = _armsSystem.GetArmEquipment(_equipmentArmIndex);
|
||||
if (equipment != null)
|
||||
{
|
||||
equipment.StopShake();
|
||||
}
|
||||
|
||||
_isVibrating = false;
|
||||
_vibrationCoroutine = null;
|
||||
}
|
||||
|
||||
private void OnEmitterEntered(AudioListenable emitter)
|
||||
{
|
||||
|
||||
emitter.SetConcentrating(true);
|
||||
|
||||
if (_contactFeedbackPrefab != null && _contactFeedback == null)
|
||||
{
|
||||
_contactFeedback = Instantiate(_contactFeedbackPrefab, emitter.transform);
|
||||
_contactFeedback.transform.localPosition = Vector3.zero;
|
||||
}
|
||||
|
||||
OnEmitterDetected?.Invoke(emitter);
|
||||
}
|
||||
|
||||
private void OnEmitterExited(AudioListenable emitter)
|
||||
{
|
||||
emitter.SetConcentrating(false);
|
||||
|
||||
if (!emitter.HasBeenTapped)
|
||||
{
|
||||
emitter.StopListening();
|
||||
}
|
||||
|
||||
if (_contactFeedback != null)
|
||||
{
|
||||
Destroy(_contactFeedback);
|
||||
_contactFeedback = null;
|
||||
}
|
||||
|
||||
if (_diapasonAnimator != null)
|
||||
{
|
||||
_diapasonAnimator.SetBool(_vibratingBoolParam, false);
|
||||
}
|
||||
|
||||
if (_diapasonParticles != null)
|
||||
{
|
||||
_diapasonParticles.Stop();
|
||||
var emission = _diapasonParticles.emission;
|
||||
emission.enabled = false;
|
||||
}
|
||||
|
||||
_isVibrating = false;
|
||||
|
||||
OnEmitterLost?.Invoke(emitter);
|
||||
}
|
||||
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_detectionPoints == null || !_isEquipped)
|
||||
return;
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
|
||||
foreach (Transform detectionPoint in _detectionPoints)
|
||||
{
|
||||
if (detectionPoint != null)
|
||||
{
|
||||
Gizmos.DrawWireSphere(detectionPoint.position, _detectionRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38b80731eb88f834ab3d79a566e40b48
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core.Audio;
|
||||
using Unheimlich.Core;
|
||||
using Unheimlich.Core.Utils;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.World.CameraSystem;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
|
||||
namespace Unheimlich.Features.Diapason
|
||||
{
|
||||
public class DiapasonListeningHandlerV2 : Singleton<DiapasonListeningHandlerV2>
|
||||
{
|
||||
[Header("Settings")]
|
||||
[SerializeField] private bool _disablePlayerMovementDuringListen = true;
|
||||
[SerializeField] private bool _enablePanMode = true;
|
||||
|
||||
[Header("Visual Feedback")]
|
||||
[Tooltip("Vignette intensity during listening (0-1)")]
|
||||
[SerializeField] [Range(0f, 1f)] private float _vignetteIntensity = 0.3f;
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private AudioClip _breathInClip;
|
||||
[SerializeField] [Range(0f, 1f)] private float _breathInVolume = 1f;
|
||||
[SerializeField] private float _concentrationDuration = 1f;
|
||||
[SerializeField] private float _breathInDuration = 0.5f;
|
||||
|
||||
public bool IsListening { get; private set; }
|
||||
public bool IsConcentrating { get; private set; }
|
||||
|
||||
private AudioSource _playerAudioSource;
|
||||
private Coroutine _listeningCoroutine;
|
||||
private CameraHeadBob _cameraHeadBob;
|
||||
private CameraPanSettings _originalCameraSettings;
|
||||
private AudioListenable _lastDetectedEmitter;
|
||||
private bool _isFadingOut;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
_playerAudioSource = GetComponent<AudioSource>();
|
||||
if (_playerAudioSource == null)
|
||||
_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 Update()
|
||||
{
|
||||
var diapasonAction = InputManager.GetDiapasonAction();
|
||||
if (diapasonAction != null && diapasonAction.WasPressedThisFrame())
|
||||
{
|
||||
if (!IsListening && !IsConcentrating)
|
||||
{
|
||||
StartListeningMode();
|
||||
}
|
||||
else if (IsListening || IsConcentrating)
|
||||
{
|
||||
StopListeningMode();
|
||||
}
|
||||
}
|
||||
|
||||
if (IsListening && PlayerController.Instance?.CameraPanController != null)
|
||||
{
|
||||
PlayerController.Instance.CameraPanController.UpdatePan();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartListeningMode()
|
||||
{
|
||||
if (_isFadingOut || PlayerController.Instance?.DiapasonEquipment == null)
|
||||
return;
|
||||
|
||||
if (_listeningCoroutine != null)
|
||||
StopCoroutine(_listeningCoroutine);
|
||||
|
||||
_listeningCoroutine = StartCoroutine(ListeningSequence());
|
||||
}
|
||||
|
||||
private IEnumerator ListeningSequence()
|
||||
{
|
||||
IsConcentrating = true;
|
||||
InputManager.SetContext(InputManager.InputContext.Listening);
|
||||
|
||||
if (_disablePlayerMovementDuringListen && PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = false;
|
||||
}
|
||||
|
||||
PlayerController.Instance.DiapasonEquipment.EquipDiapason();
|
||||
|
||||
PlayBreathIn();
|
||||
yield return new WaitForSeconds(_breathInDuration);
|
||||
|
||||
if (_cameraHeadBob != null)
|
||||
{
|
||||
float resetDuration = _concentrationDuration - _breathInDuration;
|
||||
_cameraHeadBob.LerpToZero(resetDuration);
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(_concentrationDuration - _breathInDuration);
|
||||
|
||||
IsConcentrating = false;
|
||||
IsListening = true;
|
||||
|
||||
EnableVignette(true);
|
||||
|
||||
if (_enablePanMode && PlayerController.Instance?.CameraPanController != null)
|
||||
{
|
||||
PlayerController.Instance.CameraPanController.SaveOriginalState();
|
||||
PlayerController.Instance.CameraPanController.StartPan();
|
||||
}
|
||||
|
||||
Cursor.lockState = CursorLockMode.Confined;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
|
||||
private void StopListeningMode()
|
||||
{
|
||||
if (_listeningCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_listeningCoroutine);
|
||||
_listeningCoroutine = null;
|
||||
}
|
||||
|
||||
IsListening = false;
|
||||
IsConcentrating = false;
|
||||
_isFadingOut = true;
|
||||
|
||||
PlayerController.Instance.DiapasonEquipment.UnequipDiapason();
|
||||
|
||||
EnableVignette(false);
|
||||
|
||||
if (_enablePanMode && PlayerController.Instance?.CameraPanController != null)
|
||||
{
|
||||
PlayerController.Instance.CameraPanController.StopPan();
|
||||
PlayerController.Instance.CameraPanController.ReturnToOriginalPosition(() =>
|
||||
{
|
||||
if (_originalCameraSettings != null && PlayerController.Instance?.CameraPanController != null)
|
||||
{
|
||||
PlayerController.Instance.CameraPanController.Settings = _originalCameraSettings;
|
||||
_originalCameraSettings = null;
|
||||
}
|
||||
|
||||
if (_disablePlayerMovementDuringListen && PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = true;
|
||||
}
|
||||
|
||||
_isFadingOut = false;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_disablePlayerMovementDuringListen && PlayerController.Instance?.Movement != null)
|
||||
{
|
||||
PlayerController.Instance.Movement.enabled = true;
|
||||
}
|
||||
|
||||
_isFadingOut = false;
|
||||
}
|
||||
|
||||
InputManager.SetContext(InputManager.InputContext.Normal);
|
||||
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
|
||||
private void PlayBreathIn()
|
||||
{
|
||||
if (_breathInClip != null && _playerAudioSource != null)
|
||||
{
|
||||
_playerAudioSource.PlayOneShot(_breathInClip, _breathInVolume);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableVignette(bool enable)
|
||||
{
|
||||
if (ListeningVignetteEffect.Instance == null)
|
||||
return;
|
||||
|
||||
if (enable)
|
||||
{
|
||||
ListeningVignetteEffect.Instance.EnableVignetteWithIntensity(_vignetteIntensity);
|
||||
}
|
||||
else
|
||||
{
|
||||
ListeningVignetteEffect.Instance.EnableVignette(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46c1f473fbd3cac4397aabaa76d18357
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a1920fc2ef4e5847a2b8d2dc1995c15
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff1685ec42b31a14085b5a638e78558a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,114 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core.Interaction;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.Features.Items
|
||||
{
|
||||
/// <summary>
|
||||
/// Interactable item that can be picked up and examined closely by the player.
|
||||
/// Temporarily moves to inspection position and allows rotation via look input.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider), typeof(Rigidbody))]
|
||||
public class InspectableItem : InteractableBase
|
||||
{
|
||||
[Header("Inspection Settings")]
|
||||
[SerializeField] private float _inspectionDistance = 1f;
|
||||
[SerializeField] private float _rotationSpeed = 100f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private bool _blockInteractionDuringAnimation = true;
|
||||
|
||||
private Vector3 _originalPosition;
|
||||
private Quaternion _originalRotation;
|
||||
private Transform _originalParent;
|
||||
private Rigidbody _rigidbody;
|
||||
private bool _isAnimationPlaying = false;
|
||||
private ObjectDescriptionTrigger _objectDescriptionTrigger;
|
||||
private LevitatingObject _levitatingObject;
|
||||
|
||||
public bool IsAnimationPlaying => _isAnimationPlaying;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_rigidbody = GetComponent<Rigidbody>();
|
||||
_rigidbody.isKinematic = true;
|
||||
|
||||
_originalPosition = transform.position;
|
||||
_originalRotation = transform.rotation;
|
||||
_originalParent = transform.parent;
|
||||
|
||||
_objectDescriptionTrigger = GetComponent<ObjectDescriptionTrigger>();
|
||||
_levitatingObject = GetComponent<LevitatingObject>();
|
||||
}
|
||||
|
||||
public override void Interact()
|
||||
{
|
||||
if (_blockInteractionDuringAnimation)
|
||||
{
|
||||
if (_isAnimationPlaying)
|
||||
return;
|
||||
|
||||
if (_objectDescriptionTrigger != null &&
|
||||
_objectDescriptionTrigger.IsInMergeOnceMode &&
|
||||
_objectDescriptionTrigger.IsAnimationActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (PlayerInspectionHandler.Instance != null)
|
||||
PlayerInspectionHandler.Instance.ToggleInspection(this);
|
||||
}
|
||||
|
||||
public void SetAnimationState(bool isPlaying)
|
||||
{
|
||||
_isAnimationPlaying = isPlaying;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the item for inspection by parenting it to the hold point and making it kinematic.
|
||||
/// </summary>
|
||||
public void PrepareForInspection(Transform holdPoint)
|
||||
{
|
||||
_originalPosition = transform.position;
|
||||
_originalRotation = transform.rotation;
|
||||
_originalParent = transform.parent;
|
||||
|
||||
_rigidbody.isKinematic = true;
|
||||
transform.SetParent(holdPoint);
|
||||
transform.localPosition = Vector3.forward * _inspectionDistance;
|
||||
transform.localRotation = Quaternion.identity;
|
||||
|
||||
if (_levitatingObject != null)
|
||||
{
|
||||
_levitatingObject.UpdateBasePosition();
|
||||
}
|
||||
}
|
||||
|
||||
public void Release()
|
||||
{
|
||||
transform.SetParent(_originalParent);
|
||||
transform.position = _originalPosition;
|
||||
transform.rotation = _originalRotation;
|
||||
|
||||
_rigidbody.isKinematic = true;
|
||||
|
||||
if (_levitatingObject != null)
|
||||
{
|
||||
_levitatingObject.UpdateBasePosition();
|
||||
}
|
||||
}
|
||||
|
||||
public float RotationSpeed => _rotationSpeed;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
LeanTween.init();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
LeanTween.cancel(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd19509bd29a28648a08eaf7f6146ef1
|
||||
@@ -0,0 +1,37 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Items
|
||||
{
|
||||
public class LevitatingObject : MonoBehaviour
|
||||
{
|
||||
[Header("Levitation Settings")]
|
||||
[SerializeField] private float _levitationHeight = 0.5f;
|
||||
[SerializeField] private float _levitationSpeed = 1f;
|
||||
|
||||
[Header("Rotation Settings")]
|
||||
[SerializeField] private float _rotationSpeed = 30f;
|
||||
[SerializeField] private Vector3 _rotationAxis = Vector3.up;
|
||||
|
||||
private Vector3 _startLocalPosition;
|
||||
private float _timeOffset;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_startLocalPosition = transform.localPosition;
|
||||
_timeOffset = Random.Range(0f, Mathf.PI * 2f);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float yOffset = Mathf.Sin((Time.time + _timeOffset) * _levitationSpeed) * _levitationHeight;
|
||||
transform.localPosition = _startLocalPosition + new Vector3(0f, yOffset, 0f);
|
||||
|
||||
transform.Rotate(_rotationAxis.normalized, _rotationSpeed * Time.deltaTime, Space.World);
|
||||
}
|
||||
|
||||
public void UpdateBasePosition()
|
||||
{
|
||||
_startLocalPosition = transform.localPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 899101746b780fa4ca1a66d0af13173c
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 649c28cf347c39f4da922da29b186810
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68bed47480dd3d34cb3ab7611ce22c1d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,451 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Diapason;
|
||||
|
||||
namespace Unheimlich.Features.OctoArms
|
||||
{
|
||||
|
||||
public enum ArmState
|
||||
{
|
||||
Environmental,
|
||||
TransitionToEquipped,
|
||||
Equipped,
|
||||
TransitionToEnvironmental
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class ArmEquipment
|
||||
{
|
||||
[Header("Arm References")]
|
||||
public OctoIkLimb IkLimb;
|
||||
public Transform HandSocket;
|
||||
|
||||
[Header("Bone References (for procedural animation)")]
|
||||
[Tooltip("Shoulder bone (optional)")]
|
||||
public Transform ShoulderBone;
|
||||
|
||||
[Tooltip("Elbow bone (optional)")]
|
||||
public Transform ElbowBone;
|
||||
|
||||
[Tooltip("Wrist bone (required for rotation)")]
|
||||
public Transform WristBone;
|
||||
|
||||
[Header("Equipped Pose Settings")]
|
||||
[Tooltip("Rotation to apply to shoulder when equipped")]
|
||||
public Vector3 ShoulderRotationOffset = Vector3.zero;
|
||||
|
||||
[Tooltip("Rotation to apply to elbow when equipped")]
|
||||
public Vector3 ElbowRotationOffset = Vector3.zero;
|
||||
|
||||
[Tooltip("Rotation to apply to wrist when equipped")]
|
||||
public Vector3 WristRotationOffset = new Vector3(0, 0, 90);
|
||||
|
||||
[Tooltip("Speed of rotation lerp")]
|
||||
[Range(1f, 20f)]
|
||||
public float RotationSpeed = 10f;
|
||||
|
||||
[Tooltip("Duration of IK blend after unequip transition")]
|
||||
[Range(0.1f, 1f)]
|
||||
public float IkBlendDuration = 0.3f;
|
||||
|
||||
[Header("Transition Settings")]
|
||||
[Tooltip("Position offset where arm exits camera view")]
|
||||
public Vector3 TransitionOffset = new Vector3(0.5f, -0.3f, 0.2f);
|
||||
|
||||
[Tooltip("Duration for arm to move out of view")]
|
||||
public float TransitionOutDuration = 0.3f;
|
||||
|
||||
[Tooltip("Duration for arm to return to view")]
|
||||
public float TransitionInDuration = 0.3f;
|
||||
|
||||
[Tooltip("Curve for transition movement")]
|
||||
public AnimationCurve TransitionCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
[HideInInspector] public ArmState State = ArmState.Environmental;
|
||||
[HideInInspector] public Vector3 OriginalTargetPosition;
|
||||
[HideInInspector] public Quaternion OriginalTargetRotation;
|
||||
[HideInInspector] public GameObject EquippedObject;
|
||||
[HideInInspector] public float IkWeight = 1f;
|
||||
|
||||
[HideInInspector] public Quaternion OriginalShoulderRotation;
|
||||
[HideInInspector] public Quaternion OriginalElbowRotation;
|
||||
[HideInInspector] public Quaternion OriginalWristRotation;
|
||||
|
||||
private int _transitionTweenId = -1;
|
||||
private int _ikWeightTweenId = -1;
|
||||
private int _ikBlendTweenId = -1;
|
||||
private bool _isBlendingToIk = false;
|
||||
|
||||
private int _tapTweenId = -1;
|
||||
private bool _isShaking = false;
|
||||
private TapAnimationSettings _currentShakeSettings;
|
||||
|
||||
private Quaternion _transitionStartShoulder;
|
||||
private Quaternion _transitionStartElbow;
|
||||
private Quaternion _transitionStartWrist;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (IkLimb != null && IkLimb.Target != null)
|
||||
{
|
||||
OriginalTargetPosition = IkLimb.Target.localPosition;
|
||||
OriginalTargetRotation = IkLimb.Target.localRotation;
|
||||
}
|
||||
|
||||
if (ShoulderBone != null)
|
||||
OriginalShoulderRotation = ShoulderBone.localRotation;
|
||||
|
||||
if (ElbowBone != null)
|
||||
OriginalElbowRotation = ElbowBone.localRotation;
|
||||
|
||||
if (WristBone != null)
|
||||
OriginalWristRotation = WristBone.localRotation;
|
||||
|
||||
State = ArmState.Environmental;
|
||||
IkWeight = 1f;
|
||||
}
|
||||
|
||||
public void StartTransitionToEquipped(GameObject objectToEquip, System.Action onTransitionComplete)
|
||||
{
|
||||
if (State != ArmState.Environmental)
|
||||
return;
|
||||
|
||||
State = ArmState.TransitionToEquipped;
|
||||
EquippedObject = objectToEquip;
|
||||
|
||||
CancelActiveTweens();
|
||||
|
||||
if (IkLimb == null || IkLimb.Target == null)
|
||||
{
|
||||
CompleteEquip(onTransitionComplete);
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 transitionTarget = OriginalTargetPosition + TransitionOffset;
|
||||
|
||||
_transitionTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, TransitionOutDuration)
|
||||
.setEase(TransitionCurve)
|
||||
.setOnUpdate((float t) => {
|
||||
if (IkLimb != null && IkLimb.Target != null)
|
||||
{
|
||||
IkLimb.Target.localPosition = Vector3.Lerp(IkLimb.Target.localPosition, transitionTarget, t);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_transitionTweenId = -1;
|
||||
CompleteEquip(onTransitionComplete);
|
||||
}).id;
|
||||
|
||||
_ikWeightTweenId = LeanTween.value(IkLimb.gameObject, IkWeight, 0f, TransitionOutDuration * 0.5f)
|
||||
.setOnUpdate((float val) => {
|
||||
IkWeight = val;
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_ikWeightTweenId = -1;
|
||||
}).id;
|
||||
}
|
||||
|
||||
private void CompleteEquip(System.Action onComplete)
|
||||
{
|
||||
State = ArmState.Equipped;
|
||||
|
||||
if (IkLimb != null)
|
||||
{
|
||||
IkLimb.enabled = false;
|
||||
}
|
||||
|
||||
if (EquippedObject != null && HandSocket != null)
|
||||
{
|
||||
EquippedObject.transform.SetParent(HandSocket, false);
|
||||
EquippedObject.transform.localPosition = Vector3.zero;
|
||||
EquippedObject.transform.localRotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
|
||||
public void UpdateEquippedPose()
|
||||
{
|
||||
if (State != ArmState.Equipped && !_isBlendingToIk)
|
||||
return;
|
||||
|
||||
if (_isShaking)
|
||||
{
|
||||
UpdateShake();
|
||||
return;
|
||||
}
|
||||
|
||||
if (State == ArmState.Equipped)
|
||||
{
|
||||
float deltaTime = UnityEngine.Time.deltaTime;
|
||||
float lerpSpeed = 1f - Mathf.Exp(-RotationSpeed * deltaTime);
|
||||
|
||||
if (ShoulderBone != null)
|
||||
{
|
||||
Quaternion targetRot = OriginalShoulderRotation * Quaternion.Euler(ShoulderRotationOffset);
|
||||
ShoulderBone.localRotation = Quaternion.Slerp(ShoulderBone.localRotation, targetRot, lerpSpeed);
|
||||
}
|
||||
|
||||
if (ElbowBone != null)
|
||||
{
|
||||
Quaternion targetRot = OriginalElbowRotation * Quaternion.Euler(ElbowRotationOffset);
|
||||
ElbowBone.localRotation = Quaternion.Slerp(ElbowBone.localRotation, targetRot, lerpSpeed);
|
||||
}
|
||||
|
||||
if (WristBone != null)
|
||||
{
|
||||
Quaternion targetRot = OriginalWristRotation * Quaternion.Euler(WristRotationOffset);
|
||||
WristBone.localRotation = Quaternion.Slerp(WristBone.localRotation, targetRot, lerpSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartTransitionToEnvironmental(System.Action onTransitionComplete)
|
||||
{
|
||||
if (State != ArmState.Equipped)
|
||||
return;
|
||||
|
||||
State = ArmState.TransitionToEnvironmental;
|
||||
|
||||
CancelActiveTweens();
|
||||
|
||||
_transitionStartShoulder = ShoulderBone != null ? ShoulderBone.localRotation : Quaternion.identity;
|
||||
_transitionStartElbow = ElbowBone != null ? ElbowBone.localRotation : Quaternion.identity;
|
||||
_transitionStartWrist = WristBone != null ? WristBone.localRotation : Quaternion.identity;
|
||||
|
||||
if (IkLimb == null || IkLimb.Target == null)
|
||||
{
|
||||
CompleteUnequip(onTransitionComplete);
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 currentPosition = IkLimb.Target.localPosition;
|
||||
Vector3 transitionTarget = OriginalTargetPosition + TransitionOffset;
|
||||
|
||||
_ikWeightTweenId = LeanTween.value(IkLimb.gameObject, IkWeight, 0f, TransitionInDuration * 0.5f)
|
||||
.setOnUpdate((float val) => {
|
||||
IkWeight = val;
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_ikWeightTweenId = -1;
|
||||
}).id;
|
||||
|
||||
_transitionTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, TransitionInDuration)
|
||||
.setEase(TransitionCurve)
|
||||
.setOnUpdate((float t) => {
|
||||
if (IkLimb != null && IkLimb.Target != null)
|
||||
{
|
||||
IkLimb.Target.localPosition = Vector3.Lerp(currentPosition, transitionTarget, t);
|
||||
}
|
||||
|
||||
if (ShoulderBone != null)
|
||||
ShoulderBone.localRotation = Quaternion.Slerp(_transitionStartShoulder, OriginalShoulderRotation, t);
|
||||
|
||||
if (ElbowBone != null)
|
||||
ElbowBone.localRotation = Quaternion.Slerp(_transitionStartElbow, OriginalElbowRotation, t);
|
||||
|
||||
if (WristBone != null)
|
||||
WristBone.localRotation = Quaternion.Slerp(_transitionStartWrist, OriginalWristRotation, t);
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_transitionTweenId = -1;
|
||||
StartIkBlend(onTransitionComplete);
|
||||
}).id;
|
||||
}
|
||||
|
||||
private void StartIkBlend(System.Action onTransitionComplete)
|
||||
{
|
||||
if (IkLimb != null)
|
||||
{
|
||||
IkLimb.enabled = true;
|
||||
}
|
||||
|
||||
if (EquippedObject != null)
|
||||
{
|
||||
if (HandSocket != null)
|
||||
EquippedObject.transform.SetParent(null);
|
||||
|
||||
UnityEngine.Object.Destroy(EquippedObject);
|
||||
EquippedObject = null;
|
||||
}
|
||||
|
||||
_isBlendingToIk = true;
|
||||
|
||||
Quaternion startShoulder = ShoulderBone != null ? ShoulderBone.localRotation : Quaternion.identity;
|
||||
Quaternion startElbow = ElbowBone != null ? ElbowBone.localRotation : Quaternion.identity;
|
||||
Quaternion startWrist = WristBone != null ? WristBone.localRotation : Quaternion.identity;
|
||||
Vector3 startTargetPos = IkLimb != null && IkLimb.Target != null ? IkLimb.Target.localPosition : Vector3.zero;
|
||||
Quaternion startTargetRot = IkLimb != null && IkLimb.Target != null ? IkLimb.Target.localRotation : Quaternion.identity;
|
||||
|
||||
if (IkLimb != null && IkLimb.gameObject != null)
|
||||
{
|
||||
_ikBlendTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, IkBlendDuration)
|
||||
.setEase(LeanTweenType.easeOutQuad)
|
||||
.setOnUpdate((float t) => {
|
||||
if (ShoulderBone != null)
|
||||
ShoulderBone.localRotation = Quaternion.Slerp(startShoulder, OriginalShoulderRotation, t);
|
||||
|
||||
if (ElbowBone != null)
|
||||
ElbowBone.localRotation = Quaternion.Slerp(startElbow, OriginalElbowRotation, t);
|
||||
|
||||
if (WristBone != null)
|
||||
WristBone.localRotation = Quaternion.Slerp(startWrist, OriginalWristRotation, t);
|
||||
|
||||
if (IkLimb != null && IkLimb.Target != null)
|
||||
{
|
||||
IkLimb.Target.localPosition = Vector3.Lerp(startTargetPos, OriginalTargetPosition, t);
|
||||
IkLimb.Target.localRotation = Quaternion.Slerp(startTargetRot, OriginalTargetRotation, t);
|
||||
}
|
||||
|
||||
IkWeight = t;
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_ikBlendTweenId = -1;
|
||||
CompleteUnequip(onTransitionComplete);
|
||||
}).id;
|
||||
}
|
||||
else
|
||||
{
|
||||
CompleteUnequip(onTransitionComplete);
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteUnequip(System.Action onComplete)
|
||||
{
|
||||
State = ArmState.Environmental;
|
||||
_isBlendingToIk = false;
|
||||
|
||||
if (IkLimb != null && IkLimb.Target != null)
|
||||
{
|
||||
IkLimb.Target.localPosition = OriginalTargetPosition;
|
||||
IkLimb.Target.localRotation = OriginalTargetRotation;
|
||||
}
|
||||
|
||||
if (ShoulderBone != null)
|
||||
ShoulderBone.localRotation = OriginalShoulderRotation;
|
||||
|
||||
if (ElbowBone != null)
|
||||
ElbowBone.localRotation = OriginalElbowRotation;
|
||||
|
||||
if (WristBone != null)
|
||||
WristBone.localRotation = OriginalWristRotation;
|
||||
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
|
||||
public void CancelActiveTweens()
|
||||
{
|
||||
if (_transitionTweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(_transitionTweenId);
|
||||
_transitionTweenId = -1;
|
||||
}
|
||||
|
||||
if (_ikWeightTweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(_ikWeightTweenId);
|
||||
_ikWeightTweenId = -1;
|
||||
}
|
||||
|
||||
if (_ikBlendTweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(_ikBlendTweenId);
|
||||
_ikBlendTweenId = -1;
|
||||
}
|
||||
|
||||
if (_tapTweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(_tapTweenId);
|
||||
_tapTweenId = -1;
|
||||
}
|
||||
|
||||
_isBlendingToIk = false;
|
||||
_isShaking = false;
|
||||
}
|
||||
|
||||
public void PlayTapAnimation(TapAnimationSettings settings)
|
||||
{
|
||||
if (State != ArmState.Equipped || settings == null)
|
||||
return;
|
||||
|
||||
if (_tapTweenId != -1)
|
||||
{
|
||||
LeanTween.cancel(_tapTweenId);
|
||||
}
|
||||
|
||||
Quaternion elbowStart = ElbowBone != null ? ElbowBone.localRotation : Quaternion.identity;
|
||||
Quaternion wristStart = WristBone != null ? WristBone.localRotation : Quaternion.identity;
|
||||
|
||||
Quaternion elbowWindup = elbowStart * Quaternion.Euler(0, 0, -settings.WindupRotation);
|
||||
Quaternion elbowStrike = elbowStart * Quaternion.Euler(0, 0, settings.WindupRotation * 0.3f);
|
||||
|
||||
Quaternion wristWindup = wristStart;
|
||||
Quaternion wristStrike = wristStart * Quaternion.Euler(0, 0, -settings.WristSnapRotation);
|
||||
|
||||
_tapTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, settings.WindupDuration)
|
||||
.setOnUpdate((float t) => {
|
||||
if (ElbowBone != null)
|
||||
{
|
||||
ElbowBone.localRotation = Quaternion.Slerp(elbowStart, elbowWindup, t);
|
||||
}
|
||||
if (WristBone != null)
|
||||
{
|
||||
WristBone.localRotation = Quaternion.Slerp(wristStart, wristWindup, t);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_tapTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, settings.StrikeDuration)
|
||||
.setOnUpdate((float t) => {
|
||||
if (ElbowBone != null)
|
||||
{
|
||||
ElbowBone.localRotation = Quaternion.Slerp(elbowWindup, elbowStrike, t);
|
||||
}
|
||||
if (WristBone != null)
|
||||
{
|
||||
WristBone.localRotation = Quaternion.Slerp(wristWindup, wristStrike, t);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_tapTweenId = LeanTween.value(IkLimb.gameObject, 0f, 1f, 0.1f)
|
||||
.setOnUpdate((float t) => {
|
||||
if (ElbowBone != null)
|
||||
{
|
||||
ElbowBone.localRotation = Quaternion.Slerp(elbowStrike, elbowStart, t);
|
||||
}
|
||||
if (WristBone != null)
|
||||
{
|
||||
WristBone.localRotation = Quaternion.Slerp(wristStrike, wristStart, t);
|
||||
}
|
||||
})
|
||||
.setOnComplete(() => {
|
||||
_tapTweenId = -1;
|
||||
}).id;
|
||||
}).id;
|
||||
}).id;
|
||||
}
|
||||
|
||||
public void StartShake(TapAnimationSettings settings)
|
||||
{
|
||||
_isShaking = true;
|
||||
_currentShakeSettings = settings;
|
||||
}
|
||||
|
||||
public void StopShake()
|
||||
{
|
||||
_isShaking = false;
|
||||
_currentShakeSettings = null;
|
||||
}
|
||||
|
||||
public void UpdateShake()
|
||||
{
|
||||
if (!_isShaking || State != ArmState.Equipped || WristBone == null || _currentShakeSettings == null)
|
||||
return;
|
||||
|
||||
float shake = Mathf.Sin(UnityEngine.Time.time * _currentShakeSettings.ShakeSpeed) * _currentShakeSettings.ShakeIntensity;
|
||||
Quaternion shakeRotation = Quaternion.Euler(shake, shake * 0.5f, 0);
|
||||
|
||||
Quaternion targetRot = OriginalWristRotation * Quaternion.Euler(WristRotationOffset) * shakeRotation;
|
||||
WristBone.localRotation = targetRot;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user