Advanced POC - portals, dialogue system + floating words, room loading
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90903bdc4d0191b4ea676e8add9eec91
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,127 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
public class AudioSourceDemo : MonoBehaviour
|
||||
{
|
||||
[Header("Demo Settings")]
|
||||
[SerializeField] private AudioClip _clip;
|
||||
[SerializeField] private bool _playOnStart = true;
|
||||
[SerializeField] private bool _loop = true;
|
||||
[SerializeField] private float _volume = 0.5f;
|
||||
[SerializeField] private float _sourceMinDistance = 1f;
|
||||
[SerializeField] private float _sourceMaxDistance = 20;
|
||||
|
||||
[Header("Distance Culling")]
|
||||
[SerializeField] private bool _enableDistanceCulling = true;
|
||||
[SerializeField] private float _cullingDistance = 50f;
|
||||
[SerializeField] private float _fadeStartDistance = 40f;
|
||||
|
||||
private AudioSource _source;
|
||||
private bool _hasStarted = false;
|
||||
private float _savedTime = 0f;
|
||||
private bool _wasPlaying = false;
|
||||
private bool _isCulled = false;
|
||||
private Transform _player;
|
||||
|
||||
public float OriginalVolume => _volume;
|
||||
|
||||
public float GetDistanceFadeMultiplier(Vector3 listenerPosition)
|
||||
{
|
||||
if (!_enableDistanceCulling)
|
||||
return 1f;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, listenerPosition);
|
||||
|
||||
if (distance > _cullingDistance)
|
||||
return 0f;
|
||||
|
||||
if (distance > _fadeStartDistance)
|
||||
{
|
||||
float fadeRange = _cullingDistance - _fadeStartDistance;
|
||||
float fadeAmount = (distance - _fadeStartDistance) / fadeRange;
|
||||
return 1f - Mathf.Clamp01(fadeAmount);
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_source = GetComponent<AudioSource>();
|
||||
if (_source == null)
|
||||
_source = gameObject.AddComponent<AudioSource>();
|
||||
|
||||
_source.clip = _clip;
|
||||
_source.loop = _loop;
|
||||
_source.volume = _volume;
|
||||
_source.playOnAwake = false;
|
||||
_source.spatialBlend = 1f;
|
||||
_source.rolloffMode = AudioRolloffMode.Logarithmic;
|
||||
_source.minDistance = _sourceMinDistance;
|
||||
_source.maxDistance = _sourceMaxDistance;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_wasPlaying && _savedTime > 0f)
|
||||
{
|
||||
_source.time = _savedTime;
|
||||
_source.Play();
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_playOnStart && _clip != null && !_hasStarted && !_source.isPlaying)
|
||||
{
|
||||
_source.Play();
|
||||
_hasStarted = true;
|
||||
}
|
||||
|
||||
Player playerObj = Player.Instance;
|
||||
if (playerObj != null)
|
||||
_player = playerObj.transform;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_enableDistanceCulling && _player != null && _source != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, _player.position);
|
||||
|
||||
if (distance > _cullingDistance)
|
||||
{
|
||||
if (!_isCulled)
|
||||
{
|
||||
_source.volume = 0f;
|
||||
_isCulled = true;
|
||||
}
|
||||
}
|
||||
else if (distance > _fadeStartDistance)
|
||||
{
|
||||
float fadeRange = _cullingDistance - _fadeStartDistance;
|
||||
float fadeAmount = (distance - _fadeStartDistance) / fadeRange;
|
||||
fadeAmount = Mathf.Clamp01(fadeAmount);
|
||||
_source.volume = _volume * (1f - fadeAmount);
|
||||
_isCulled = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_isCulled || _source.volume != _volume)
|
||||
{
|
||||
_source.volume = _volume;
|
||||
_isCulled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_wasPlaying = _source.isPlaying;
|
||||
_savedTime = _source.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f399329860533c14fba15187d7e340db
|
||||
@@ -0,0 +1,63 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
public class AudioSourceInspector : MonoBehaviour
|
||||
{
|
||||
[Header("Debug Settings")]
|
||||
[SerializeField] private float _logInterval = 1f;
|
||||
|
||||
private AudioSource _source;
|
||||
private float _lastLogTime;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_source = GetComponent<AudioSource>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_source == null) return;
|
||||
|
||||
if (Time.time - _lastLogTime >= _logInterval)
|
||||
{
|
||||
LogAudioSourceState();
|
||||
_lastLogTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Debug.Log($"<color=cyan>[AudioInspector] OnEnable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Debug.Log($"<color=magenta>[AudioInspector] OnDisable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void LogAudioSourceState()
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
Debug.LogWarning($"[AudioInspector] No AudioSource on {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
string clipName = _source.clip != null ? _source.clip.name : "NULL";
|
||||
Debug.Log($"<color=yellow>[AudioInspector] {gameObject.name} | " +
|
||||
$"Clip: {clipName} | " +
|
||||
$"Time: {_source.time:F2}s | " +
|
||||
$"isPlaying: {_source.isPlaying} | " +
|
||||
$"enabled: {_source.enabled} | " +
|
||||
$"volume: {_source.volume:F2} | " +
|
||||
$"mute: {_source.mute} | " +
|
||||
$"loop: {_source.loop} | " +
|
||||
$"Active: {gameObject.activeInHierarchy}</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65fea0a39ddb811419ec546ea98e5917
|
||||
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
public class PortalAudioDebugger : MonoBehaviour
|
||||
{
|
||||
private Portal _portal;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_portal = GetComponent<Portal>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded += OnRoomLoadedDebug;
|
||||
_portal.RoomUnloaded += OnRoomUnloadedDebug;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded -= OnRoomLoadedDebug;
|
||||
_portal.RoomUnloaded -= OnRoomUnloadedDebug;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoomLoadedDebug()
|
||||
{
|
||||
Debug.Log($"<color=green>[PortalDebug] RoomLoaded event fired on {gameObject.name}</color>");
|
||||
|
||||
if (_portal.LoadedRoomInstance != null)
|
||||
{
|
||||
AudioSource[] sources = _portal.LoadedRoomInstance.GetComponentsInChildren<AudioSource>();
|
||||
Debug.Log($"<color=green>[PortalDebug] Found {sources.Length} AudioSources in loaded room:</color>");
|
||||
|
||||
foreach (var source in sources)
|
||||
Debug.Log($" - {source.name} | Playing: {source.isPlaying} | Clip: {(source.clip != null ? source.clip.name : "NULL")}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoomUnloadedDebug()
|
||||
{
|
||||
Debug.Log($"<color=red>[PortalDebug] RoomUnloaded event fired on {gameObject.name}</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 897784c22112f5f48af4313022397d8e
|
||||
@@ -0,0 +1,333 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates virtual audio proxies for sounds coming from the other side of a portal.
|
||||
/// When a portal links to a room, sounds from that room are projected through the portal
|
||||
/// so the player hears them spatially from the portal's location. This creates seamless
|
||||
/// audio transitions when walking through portals.
|
||||
/// </summary>
|
||||
public class PortalAudioProxy : MonoBehaviour
|
||||
{
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _maxDistance = 20f;
|
||||
[SerializeField] private float _fadeStartDistance = 15f;
|
||||
[SerializeField] private float _transitionDistance = 2f;
|
||||
[SerializeField] private bool _useLowPassFilter = true;
|
||||
[SerializeField] private float _lowPassCutoff = 5000f;
|
||||
[SerializeField] private float _timeSyncThreshold = 0.1f;
|
||||
[SerializeField] private float _minSpread = 60f;
|
||||
[SerializeField] private float _maxSpread = 180f;
|
||||
[SerializeField] private float _volumeRolloffPower = 2f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private List<AudioSource> _remoteSources = new List<AudioSource>();
|
||||
|
||||
private Portal _portal;
|
||||
private Dictionary<AudioSource, AudioSource> _proxyMapping = new Dictionary<AudioSource, AudioSource>();
|
||||
private Dictionary<AudioSource, AudioLowPassFilter> _filterMapping = new Dictionary<AudioSource, AudioLowPassFilter>();
|
||||
|
||||
private GameObject _lastLoadedRoomInstance;
|
||||
private bool _needsRefresh;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_portal = GetComponent<Portal>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded += OnRoomLoaded;
|
||||
_portal.RoomUnloaded += OnRoomUnloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_portal != null)
|
||||
{
|
||||
_portal.RoomLoaded -= OnRoomLoaded;
|
||||
_portal.RoomUnloaded -= OnRoomUnloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoomLoaded()
|
||||
{
|
||||
_needsRefresh = true;
|
||||
}
|
||||
|
||||
private void OnRoomUnloaded()
|
||||
{
|
||||
_needsRefresh = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main update loop: refreshes audio sources when the room changes,
|
||||
/// then updates all proxy positions and volumes each frame.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
if (_portal == null || _portal.LinkedPortal == null)
|
||||
return;
|
||||
|
||||
GameObject currentLoadedRoom = _portal.LoadedRoomInstance;
|
||||
|
||||
if (currentLoadedRoom != _lastLoadedRoomInstance)
|
||||
{
|
||||
_lastLoadedRoomInstance = currentLoadedRoom;
|
||||
_needsRefresh = true;
|
||||
}
|
||||
|
||||
if (_needsRefresh)
|
||||
{
|
||||
RefreshRemoteSources();
|
||||
_needsRefresh = false;
|
||||
}
|
||||
|
||||
UpdateProxies();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the loaded room for all AudioSource components and caches them.
|
||||
/// Called whenever a new room is loaded through the portal.
|
||||
/// </summary>
|
||||
private void RefreshRemoteSources()
|
||||
{
|
||||
GameObject linkedRoom = _portal.LoadedRoomInstance;
|
||||
if (linkedRoom == null)
|
||||
{
|
||||
_remoteSources.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
AudioSource[] sources = linkedRoom.GetComponentsInChildren<AudioSource>();
|
||||
|
||||
_remoteSources.Clear();
|
||||
_remoteSources.AddRange(sources);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates all audio proxies each frame:
|
||||
/// - Syncs playback state with remote sources
|
||||
/// - Positions proxies at the portal location
|
||||
/// - Adjusts volume based on distance and player orientation
|
||||
/// - Applies low-pass filtering for sounds through the portal
|
||||
/// This creates the illusion that sounds are coming from the portal opening.
|
||||
/// </summary>
|
||||
private void UpdateProxies()
|
||||
{
|
||||
Camera playerCam = Camera.main;
|
||||
if (playerCam == null)
|
||||
return;
|
||||
|
||||
Portal linkedPortal = _portal.LinkedPortal;
|
||||
if (linkedPortal == null)
|
||||
return;
|
||||
|
||||
Vector3 playerPos = playerCam.transform.position;
|
||||
Vector3 portalAPos = transform.position;
|
||||
|
||||
float distPlayerToPortalA = Vector3.Distance(playerPos, portalAPos);
|
||||
|
||||
Vector3 offsetFromPortalA = playerPos - portalAPos;
|
||||
float dotToPortalA = Vector3.Dot(offsetFromPortalA.normalized, transform.forward);
|
||||
bool playerFacingPortal = dotToPortalA > 0;
|
||||
|
||||
float transitionBlend = 0f;
|
||||
if (playerFacingPortal && distPlayerToPortalA < _transitionDistance)
|
||||
{
|
||||
transitionBlend = 1f - (distPlayerToPortalA / _transitionDistance);
|
||||
}
|
||||
|
||||
List<AudioSource> toRemove = new List<AudioSource>();
|
||||
|
||||
foreach (var remoteSource in _remoteSources)
|
||||
{
|
||||
if (remoteSource == null)
|
||||
{
|
||||
toRemove.Add(remoteSource);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!remoteSource.isPlaying)
|
||||
{
|
||||
if (_proxyMapping.ContainsKey(remoteSource))
|
||||
{
|
||||
AudioSource proxy = _proxyMapping[remoteSource];
|
||||
if (proxy != null && proxy.isPlaying)
|
||||
proxy.Stop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
AudioSource localProxy = GetOrCreateProxy(remoteSource);
|
||||
|
||||
if (localProxy.clip != remoteSource.clip)
|
||||
{
|
||||
localProxy.clip = remoteSource.clip;
|
||||
localProxy.loop = remoteSource.loop;
|
||||
localProxy.time = remoteSource.time;
|
||||
localProxy.Play();
|
||||
}
|
||||
else if (!localProxy.isPlaying)
|
||||
{
|
||||
localProxy.time = remoteSource.time;
|
||||
localProxy.Play();
|
||||
}
|
||||
else
|
||||
{
|
||||
float timeDifference = Mathf.Abs(localProxy.time - remoteSource.time);
|
||||
if (timeDifference > _timeSyncThreshold)
|
||||
localProxy.time = remoteSource.time;
|
||||
}
|
||||
|
||||
localProxy.pitch = remoteSource.pitch;
|
||||
|
||||
if (_useLowPassFilter && _filterMapping.ContainsKey(remoteSource))
|
||||
{
|
||||
AudioLowPassFilter filter = _filterMapping[remoteSource];
|
||||
if (filter != null)
|
||||
filter.enabled = !_portal.IsDoorOpen;
|
||||
}
|
||||
|
||||
Vector3 virtualPlayerPos = linkedPortal.transform.TransformPoint(transform.InverseTransformPoint(playerPos));
|
||||
|
||||
Vector3 proxyWorldPos = transform.TransformPoint(linkedPortal.transform.InverseTransformPoint(remoteSource.transform.position));
|
||||
localProxy.transform.position = proxyWorldPos;
|
||||
|
||||
float baseVolume = remoteSource.volume;
|
||||
AudioSourceDemo demo = remoteSource.GetComponent<AudioSourceDemo>();
|
||||
if (demo != null)
|
||||
baseVolume = demo.OriginalVolume;
|
||||
|
||||
localProxy.volume = baseVolume * (1f - transitionBlend);
|
||||
|
||||
if (demo != null)
|
||||
{
|
||||
float sourceFade = demo.GetDistanceFadeMultiplier(virtualPlayerPos);
|
||||
localProxy.volume *= sourceFade;
|
||||
}
|
||||
|
||||
float distanceToPortal = Vector3.Distance(playerPos, portalAPos);
|
||||
if (distanceToPortal > _fadeStartDistance)
|
||||
{
|
||||
float fadeRange = _maxDistance - _fadeStartDistance;
|
||||
float fadeAmount = (distanceToPortal - _fadeStartDistance) / fadeRange;
|
||||
fadeAmount = Mathf.Clamp01(fadeAmount);
|
||||
localProxy.volume *= (1f - fadeAmount);
|
||||
}
|
||||
|
||||
if (localProxy.volume <= PortalConstants.MIN_VOLUME_THRESHOLD && localProxy.isPlaying)
|
||||
{
|
||||
localProxy.Stop();
|
||||
continue;
|
||||
}
|
||||
|
||||
localProxy.spread = Mathf.Lerp(_minSpread, _maxSpread, transitionBlend);
|
||||
}
|
||||
|
||||
foreach (var source in toRemove)
|
||||
{
|
||||
if (_proxyMapping.ContainsKey(source))
|
||||
{
|
||||
AudioSource proxy = _proxyMapping[source];
|
||||
if (proxy != null)
|
||||
Destroy(proxy.gameObject);
|
||||
|
||||
_proxyMapping.Remove(source);
|
||||
}
|
||||
|
||||
if (_filterMapping.ContainsKey(source))
|
||||
_filterMapping.Remove(source);
|
||||
|
||||
_remoteSources.Remove(source);
|
||||
}
|
||||
}
|
||||
|
||||
private float CalculateVolumeFromDistance(float distance, AudioSource source)
|
||||
{
|
||||
float baseVolume = source.volume;
|
||||
|
||||
AudioSourceDemo demo = source.GetComponent<AudioSourceDemo>();
|
||||
if (demo != null)
|
||||
baseVolume = demo.OriginalVolume;
|
||||
|
||||
if (distance > source.maxDistance)
|
||||
return 0f;
|
||||
|
||||
if (distance < source.minDistance)
|
||||
return baseVolume;
|
||||
|
||||
switch (source.rolloffMode)
|
||||
{
|
||||
case AudioRolloffMode.Logarithmic:
|
||||
float normalizedDist = (distance - source.minDistance) / (source.maxDistance - source.minDistance);
|
||||
float logVolume = baseVolume * (1f / (1f + normalizedDist * 10f));
|
||||
return logVolume * Mathf.Pow(1f - normalizedDist, _volumeRolloffPower);
|
||||
|
||||
case AudioRolloffMode.Linear:
|
||||
float linearFactor = 1f - ((distance - source.minDistance) / (source.maxDistance - source.minDistance));
|
||||
linearFactor = Mathf.Pow(linearFactor, _volumeRolloffPower);
|
||||
return baseVolume * Mathf.Max(0f, linearFactor);
|
||||
|
||||
default:
|
||||
return baseVolume;
|
||||
}
|
||||
}
|
||||
|
||||
private AudioSource GetOrCreateProxy(AudioSource remoteSource)
|
||||
{
|
||||
if (_proxyMapping.ContainsKey(remoteSource) && _proxyMapping[remoteSource] != null)
|
||||
return _proxyMapping[remoteSource];
|
||||
|
||||
GameObject proxyObj = new GameObject($"AudioProxy_{remoteSource.name}");
|
||||
proxyObj.transform.SetParent(transform);
|
||||
proxyObj.transform.localPosition = Vector3.zero;
|
||||
|
||||
AudioSource proxy = proxyObj.AddComponent<AudioSource>();
|
||||
proxy.spatialBlend = 1f;
|
||||
proxy.spatialize = true;
|
||||
proxy.spatializePostEffects = true;
|
||||
proxy.reverbZoneMix = 1f;
|
||||
proxy.spread = 60f;
|
||||
proxy.rolloffMode = remoteSource.rolloffMode;
|
||||
proxy.minDistance = remoteSource.minDistance;
|
||||
proxy.maxDistance = remoteSource.maxDistance;
|
||||
proxy.playOnAwake = false;
|
||||
|
||||
if (_useLowPassFilter)
|
||||
{
|
||||
AudioLowPassFilter filter = proxyObj.AddComponent<AudioLowPassFilter>();
|
||||
filter.cutoffFrequency = _lowPassCutoff;
|
||||
filter.enabled = false;
|
||||
_filterMapping[remoteSource] = filter;
|
||||
}
|
||||
|
||||
_proxyMapping[remoteSource] = proxy;
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
foreach (var kvp in _proxyMapping)
|
||||
{
|
||||
if (kvp.Value != null)
|
||||
Destroy(kvp.Value.gameObject);
|
||||
}
|
||||
_proxyMapping.Clear();
|
||||
_filterMapping.Clear();
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(transform.position, _maxDistance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ef7477556cb1fa45ab6cf4a9422c405
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3bc8e4216ee36534d89573f82ca94168
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8187494b7ec5bb4cad805579f1a1789
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,124 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Data.Dialogues
|
||||
{
|
||||
/// <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;
|
||||
|
||||
[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;
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
public enum ChoiceSpawnMode
|
||||
{
|
||||
AroundAnchor,
|
||||
AtAnchorPosition,
|
||||
InFrontOfPlayer,
|
||||
CircleAroundPlayer
|
||||
}
|
||||
|
||||
public enum RotationAxis
|
||||
{
|
||||
Forward,
|
||||
Up,
|
||||
Right
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aec9b5da9003c646be8289148a4a405
|
||||
@@ -0,0 +1,30 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Data.Dialogues
|
||||
{
|
||||
[System.Serializable]
|
||||
public class DialogueChoiceData
|
||||
{
|
||||
[Header("Choice Content")]
|
||||
[TextArea(2, 5)]
|
||||
[Tooltip("Text displayed on the choice button")]
|
||||
public string ChoiceText;
|
||||
|
||||
[Header("Localization")]
|
||||
[Tooltip("Localization key for the choice text")]
|
||||
public string ChoiceTextLocalizationKey;
|
||||
|
||||
[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 bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrEmpty(ChoiceText) && NextNode != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a3b0ea4c9c5bec419eac7026710bfc9
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Data.Dialogues
|
||||
{
|
||||
[CreateAssetMenu(fileName = "NewConversation", menuName = "Unheimlich/Dialogue/Conversation")]
|
||||
public class DialogueConversation : ScriptableObject
|
||||
{
|
||||
[Header("Speaker Information")]
|
||||
[Tooltip("Name of the speaker (common to all texts in this conversation)")]
|
||||
public string SpeakerName;
|
||||
|
||||
[Header("Dialogue Flow")]
|
||||
[Tooltip("Ordered list of dialogue nodes for this conversation")]
|
||||
public List<DialogueNode> DialogueNodes = new List<DialogueNode>();
|
||||
|
||||
[Header("Localization")]
|
||||
[Tooltip("Localization ID for speaker name (optional)")]
|
||||
public string SpeakerNameLocalizationKey;
|
||||
|
||||
public DialogueNode GetFirstNode()
|
||||
{
|
||||
return DialogueNodes != null && DialogueNodes.Count > 0 ? DialogueNodes[0] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83cc2feccc2da50418d37dfef21a2e99
|
||||
@@ -0,0 +1,366 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace Unheimlich.Data.Dialogues
|
||||
{
|
||||
public static class DialogueConversationBuilder
|
||||
{
|
||||
public static DialogueNode CreateSimpleNode(string dialogueText, params (string choiceText, DialogueNode nextNode)[] choices)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
DialogueNode node = ScriptableObject.CreateInstance<DialogueNode>();
|
||||
node.DialogueText = dialogueText;
|
||||
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
DialogueChoiceData choiceData = new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = choice.choiceText,
|
||||
NextNode = choice.nextNode
|
||||
};
|
||||
node.Choices.Add(choiceData);
|
||||
}
|
||||
|
||||
return node;
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
public static DialogueConversation CreateConversation(string speakerName, params DialogueNode[] nodes)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
DialogueConversation conversation = ScriptableObject.CreateInstance<DialogueConversation>();
|
||||
conversation.SpeakerName = speakerName;
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node != null)
|
||||
conversation.DialogueNodes.Add(node);
|
||||
}
|
||||
|
||||
return conversation;
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#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("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!", 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 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);
|
||||
}
|
||||
|
||||
List<DialogueNode> createdNodes = new List<DialogueNode>();
|
||||
|
||||
for (int i = 0; i < _nodes.Count; i++)
|
||||
{
|
||||
NodeTemplate template = _nodes[i];
|
||||
DialogueNode node = CreateInstance<DialogueNode>();
|
||||
node.DialogueText = template.NodeText;
|
||||
node.name = $"Node_{i + 1}";
|
||||
|
||||
string nodePath = $"{folderPath}/{node.name}.asset";
|
||||
AssetDatabase.CreateAsset(node, nodePath);
|
||||
createdNodes.Add(node);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
DialogueChoiceData choice = new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = choiceTemplate.Text,
|
||||
NextNode = createdNodes[targetIndex]
|
||||
};
|
||||
currentNode.Choices.Add(choice);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(currentNode);
|
||||
}
|
||||
|
||||
DialogueConversation conversation = CreateInstance<DialogueConversation>();
|
||||
conversation.SpeakerName = _speakerName;
|
||||
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\nPath: {folderPath}", "OK");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 863ff46c7eaea694e97b5e51782b7119
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Data.Dialogues
|
||||
{
|
||||
[CreateAssetMenu(fileName = "NewDialogueNode", menuName = "Unheimlich/Dialogue/Dialogue Node")]
|
||||
public class DialogueNode : ScriptableObject
|
||||
{
|
||||
[Header("Content")]
|
||||
[TextArea(3, 10)]
|
||||
[Tooltip("Text displayed for this dialogue node")]
|
||||
public string DialogueText;
|
||||
|
||||
[Header("Localization")]
|
||||
[Tooltip("Localization key for the dialogue text")]
|
||||
public string DialogueTextLocalizationKey;
|
||||
|
||||
[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;
|
||||
|
||||
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.Data.Dialogues
|
||||
{
|
||||
[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: 4c908d265525e8743b3eacda7f1ad0f5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
|
||||
[CustomEditor(typeof(DialogueConversation))]
|
||||
public class DialogueConversationEditor : Editor
|
||||
{
|
||||
private SerializedProperty _speakerNameProp;
|
||||
private SerializedProperty _speakerNameLocalizationKeyProp;
|
||||
private SerializedProperty _dialogueNodesProp;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_speakerNameProp = serializedObject.FindProperty("SpeakerName");
|
||||
_speakerNameLocalizationKeyProp = serializedObject.FindProperty("SpeakerNameLocalizationKey");
|
||||
_dialogueNodesProp = serializedObject.FindProperty("DialogueNodes");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Conversation Setup", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_speakerNameProp);
|
||||
EditorGUILayout.PropertyField(_speakerNameLocalizationKeyProp, new GUIContent("Localization Key (Optional)"));
|
||||
|
||||
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 = ScriptableObject.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3385c171e333c347bcf6f68b953acdb
|
||||
@@ -0,0 +1,99 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
|
||||
[CustomEditor(typeof(DialogueNode))]
|
||||
public class DialogueNodeEditor : Editor
|
||||
{
|
||||
private SerializedProperty _dialogueTextProp;
|
||||
private SerializedProperty _dialogueTextLocalizationKeyProp;
|
||||
private SerializedProperty _choicesProp;
|
||||
private SerializedProperty _autoCloseDelayProp;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_dialogueTextProp = serializedObject.FindProperty("DialogueText");
|
||||
_dialogueTextLocalizationKeyProp = serializedObject.FindProperty("DialogueTextLocalizationKey");
|
||||
_choicesProp = serializedObject.FindProperty("Choices");
|
||||
_autoCloseDelayProp = serializedObject.FindProperty("AutoCloseDelay");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.LabelField("Node Content", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_dialogueTextProp);
|
||||
EditorGUILayout.PropertyField(_dialogueTextLocalizationKeyProp, new GUIContent("Localization Key (Optional)"));
|
||||
|
||||
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.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 = "New choice",
|
||||
NextNode = null
|
||||
};
|
||||
|
||||
node.Choices.Add(newChoice);
|
||||
EditorUtility.SetDirty(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91d402e79d02c8246b21d7f72df91e84
|
||||
@@ -0,0 +1,31 @@
|
||||
#if UNITY_EDITOR
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
|
||||
[InitializeOnLoad]
|
||||
public static class HierarchySectionHeader
|
||||
{
|
||||
static HierarchySectionHeader()
|
||||
{
|
||||
EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
|
||||
}
|
||||
|
||||
static void HierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
|
||||
{
|
||||
var gameObject = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
|
||||
|
||||
if (gameObject != null && gameObject.name.StartsWith("//", System.StringComparison.Ordinal))
|
||||
{
|
||||
EditorGUI.DrawRect(selectionRect, Color.grey);
|
||||
|
||||
EditorGUI.LabelField(selectionRect, gameObject.name.Replace("/", "").ToUpperInvariant(), new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 12,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
normal = { textColor = Color.black }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8e4f480645562e48bfd2214400c5d2d
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 446234df3c4c71942a83549b6f633648
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 262316d1e2fff924ab5e318b383736cb
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d918f17293f2144bb062aedf3c87c64
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
|
||||
namespace Unheimlich.Gameplay.Environment
|
||||
{
|
||||
public class DoorInteractable : InteractableBase
|
||||
{
|
||||
[Header("Door Settings")]
|
||||
public bool IsOpen = false;
|
||||
[SerializeField] private float _openAngle = 90f;
|
||||
[SerializeField] private float _animationDuration = 0.5f;
|
||||
[SerializeField] private LeanTweenType _easeType = LeanTweenType.easeInOutQuad;
|
||||
|
||||
private Quaternion _closedRotation;
|
||||
private Quaternion _openRotation;
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
_closedRotation = transform.rotation;
|
||||
_openRotation = _closedRotation * Quaternion.Euler(0, _openAngle, 0);
|
||||
|
||||
InteractionText = IsOpen ? "Close" : "Open";
|
||||
}
|
||||
|
||||
public override void Interact(GameObject interactor)
|
||||
{
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public void ToggleDoor()
|
||||
{
|
||||
LeanTween.cancel(gameObject);
|
||||
|
||||
if (!IsOpen)
|
||||
{
|
||||
LeanTween.rotate(gameObject, _openRotation.eulerAngles, _animationDuration)
|
||||
.setEase(_easeType);
|
||||
|
||||
IsOpen = true;
|
||||
InteractionText = "Close";
|
||||
}
|
||||
else
|
||||
{
|
||||
LeanTween.rotate(gameObject, _closedRotation.eulerAngles, _animationDuration)
|
||||
.setEase(_easeType);
|
||||
|
||||
IsOpen = false;
|
||||
InteractionText = "Open";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d620ab476d55f2542a31783a6684f189
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98264b75876e33a48a42fe7128eddc19
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,102 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
using Unheimlich.Interactions.FloatingWords;
|
||||
|
||||
namespace Unheimlich.Gameplay.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;
|
||||
|
||||
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>();
|
||||
}
|
||||
|
||||
public override void Interact(GameObject interactor)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
public void Release()
|
||||
{
|
||||
transform.SetParent(_originalParent);
|
||||
transform.position = _originalPosition;
|
||||
transform.rotation = _originalRotation;
|
||||
|
||||
_rigidbody.isKinematic = true;
|
||||
}
|
||||
|
||||
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,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7d7d4707026b6d40be69401e67ae554
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulates camera head bobbing motion during player movement and idle states.
|
||||
/// Applies subtle oscillation based on player grounded and movement state.
|
||||
/// </summary>
|
||||
public class CameraHeadBob : MonoBehaviour
|
||||
{
|
||||
[Header("Bob Settings - Idle")]
|
||||
[SerializeField] private float _idleBobSpeed = 1f;
|
||||
[SerializeField] private float _idleBobAmplitude = 0.005f;
|
||||
|
||||
[Header("Bob Settings - Walk")]
|
||||
[SerializeField] private float _walkBobSpeed = 12f;
|
||||
[SerializeField] private float _walkBobAmplitude = 0.02f;
|
||||
[SerializeField] private float _resetSpeed = 5f;
|
||||
|
||||
private PlayerMovement _playerMovement;
|
||||
private float _timer = 0f;
|
||||
private Vector3 _startLocalPos;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_playerMovement = PlayerMovement.Instance;
|
||||
_startLocalPos = transform.localPosition;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_playerMovement == null || _playerMovement.CharacterController == null)
|
||||
return;
|
||||
|
||||
if (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting)
|
||||
{
|
||||
ApplyBob(_idleBobSpeed, _idleBobAmplitude);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isMoving = _playerMovement.IsMoving;
|
||||
bool isGrounded = _playerMovement.CharacterController.isGrounded;
|
||||
|
||||
if (isGrounded)
|
||||
{
|
||||
if (isMoving)
|
||||
ApplyBob(_walkBobSpeed, _walkBobAmplitude);
|
||||
else
|
||||
ApplyBob(_idleBobSpeed, _idleBobAmplitude);
|
||||
}
|
||||
else
|
||||
ResetPosition();
|
||||
}
|
||||
|
||||
private void ApplyBob(float speed, float amplitude)
|
||||
{
|
||||
_timer += Time.deltaTime * speed;
|
||||
|
||||
float x = Mathf.Sin(_timer * 0.5f) * amplitude;
|
||||
float y = Mathf.Cos(_timer) * amplitude;
|
||||
|
||||
Vector3 newPos = _startLocalPos;
|
||||
newPos.x += x;
|
||||
newPos.y += y;
|
||||
|
||||
transform.localPosition = newPos;
|
||||
}
|
||||
|
||||
private void ResetPosition()
|
||||
{
|
||||
transform.localPosition = Vector3.Lerp(transform.localPosition, _startLocalPos, Time.deltaTime * _resetSpeed);
|
||||
|
||||
if (Vector3.Distance(transform.localPosition, _startLocalPos) < 0.001f)
|
||||
_timer = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2e180d338cc26242a3a9d962eea7f7c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -200
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton player reference accessible globally throughout the game.
|
||||
/// </summary>
|
||||
public class Player : MonoBehaviour
|
||||
{
|
||||
public static Player Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47a9ebc2bd5f9345ad873a400c3d8bf
|
||||
@@ -0,0 +1,165 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
using InputManager = Unheimlich.Managers.PlayerInputManager;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles first-person camera rotation and look mechanics for the player.
|
||||
/// Supports both mouse and gamepad input with adaptive sensitivity.
|
||||
/// Includes aim assist for dialogue choices and camera shake effects.
|
||||
/// </summary>
|
||||
public class PlayerLook : MonoBehaviour
|
||||
{
|
||||
private const float INPUT_DEADZONE = 0.1f;
|
||||
private const float MOUSE_DELTA_MULTIPLIER = 0.001f;
|
||||
|
||||
public static PlayerLook Instance { get; private set; }
|
||||
|
||||
[Header("Components")]
|
||||
[SerializeField] private Transform _playerCamera, _playerCameraPivot;
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private int _frameRate = 120;
|
||||
[SerializeField] private float _mouseSensitivity = 0.2f;
|
||||
[SerializeField] private float _gamepadSensitivity = 100f;
|
||||
[SerializeField] private float _minLookAngle = -60f;
|
||||
[SerializeField] private float _maxLookAngle = 60f;
|
||||
|
||||
[Header("Aim Assist")]
|
||||
[SerializeField] private float _aimAssistStrength = 25f;
|
||||
[SerializeField] private float _aimAssistMaxAngle = 10f;
|
||||
|
||||
public bool HasActiveLookInput { get; private set; }
|
||||
public bool IsAimLocked { get; private set; } = false;
|
||||
|
||||
[Header("Shake Control")]
|
||||
[SerializeField] private float _shakeFrequency = 40f;
|
||||
private float _shakeInterval;
|
||||
private float _shakeTimer = 0f;
|
||||
|
||||
private float _horizontalRotation;
|
||||
private float _verticalRotation;
|
||||
private bool _isUsingGamepad = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
QualitySettings.vSyncCount = 0;
|
||||
Application.targetFrameRate = _frameRate;
|
||||
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
_shakeInterval = 1f / _shakeFrequency;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if(!InputManager.IsLookingEnabled)
|
||||
return;
|
||||
|
||||
HandleLook();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes look input from mouse or gamepad and updates camera rotation.
|
||||
/// Automatically detects input device and applies appropriate sensitivity.
|
||||
/// Clamps vertical rotation to prevent over-rotation.
|
||||
/// </summary>
|
||||
private void HandleLook()
|
||||
{
|
||||
Vector2 lookInput = InputManager.Instance.LookInput;
|
||||
|
||||
HasActiveLookInput = lookInput.magnitude > INPUT_DEADZONE;
|
||||
|
||||
if (Gamepad.current != null && Gamepad.current.rightStick.ReadValue().magnitude > PortalConstants.GAMEPAD_DEADZONE)
|
||||
_isUsingGamepad = true;
|
||||
else if (Mouse.current != null && Mouse.current.delta.ReadValue().magnitude > PortalConstants.GAMEPAD_DEADZONE)
|
||||
_isUsingGamepad = false;
|
||||
|
||||
float sensitivity = _isUsingGamepad ? _gamepadSensitivity : _mouseSensitivity;
|
||||
float deltaMultiplier = _isUsingGamepad ? Time.deltaTime : MOUSE_DELTA_MULTIPLIER;
|
||||
|
||||
_horizontalRotation += lookInput.x * sensitivity * deltaMultiplier;
|
||||
transform.rotation = Quaternion.Euler(0, _horizontalRotation, 0f);
|
||||
|
||||
_verticalRotation -= lookInput.y * sensitivity * deltaMultiplier;
|
||||
_verticalRotation = Mathf.Clamp(_verticalRotation, _minLookAngle, _maxLookAngle);
|
||||
|
||||
_playerCamera.localRotation = Quaternion.Euler(_verticalRotation, 0f, 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smoothly rotates the player camera toward a target using spherical interpolation.
|
||||
/// Assists player aiming by gently pulling the view toward dialogue choices when looking near them.
|
||||
/// Automatically unlocks if player provides manual input.
|
||||
/// </summary>
|
||||
public void ApplyAimAssist(Vector3 targetCenter)
|
||||
{
|
||||
if (IsAimLocked && HasActiveLookInput)
|
||||
IsAimLocked = false;
|
||||
|
||||
Vector3 directionToTarget = (targetCenter - _playerCamera.position).normalized;
|
||||
float angleError = Vector3.Angle(_playerCamera.forward, directionToTarget);
|
||||
|
||||
if (IsAimLocked || (angleError > 0.01f && angleError < _aimAssistMaxAngle))
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
|
||||
|
||||
Quaternion correctedRotation = Quaternion.Slerp(
|
||||
_playerCamera.rotation,
|
||||
targetRotation,
|
||||
_aimAssistStrength * Time.deltaTime
|
||||
);
|
||||
Quaternion newBodyRotation = Quaternion.Euler(0, correctedRotation.eulerAngles.y, 0);
|
||||
|
||||
_horizontalRotation = newBodyRotation.eulerAngles.y;
|
||||
transform.rotation = newBodyRotation;
|
||||
|
||||
float newVerticalAngle = correctedRotation.eulerAngles.x;
|
||||
|
||||
if (newVerticalAngle > 180) newVerticalAngle -= 360;
|
||||
|
||||
_verticalRotation = Mathf.Clamp(newVerticalAngle, _minLookAngle, _maxLookAngle);
|
||||
_playerCamera.localRotation = Quaternion.Euler(_verticalRotation, 0f, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a random camera shake effect for feedback (e.g., impacts, events).
|
||||
/// Uses a timer-based interval to control shake frequency.
|
||||
/// Amplitude controls both the intensity and duration of the shake.
|
||||
/// </summary>
|
||||
public void Shake(float amplitude)
|
||||
{
|
||||
_shakeTimer -= Time.deltaTime;
|
||||
|
||||
if (_shakeTimer <= 0f)
|
||||
{
|
||||
_shakeTimer = _shakeInterval * amplitude;
|
||||
|
||||
float x = Random.Range(-1f, 1f) * amplitude;
|
||||
float y = Random.Range(-1f, 1f) * amplitude;
|
||||
|
||||
Vector3 originalPos = _playerCameraPivot.position;
|
||||
_playerCameraPivot.position = originalPos + new Vector3(x, y, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public void LockAim(bool locked)
|
||||
{
|
||||
IsAimLocked = locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ddcefd49548ddb4b9bc694da032caee
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Managers;
|
||||
|
||||
namespace Unheimlich.Gameplay.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles player movement, jumping, and gravity using CharacterController.
|
||||
/// Provides movement state information for other systems like camera head bob.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public class PlayerMovement : MonoBehaviour
|
||||
{
|
||||
public static PlayerMovement Instance { get; private set; }
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _moveSpeed = 5f;
|
||||
[SerializeField] private float _jumpForce = 10f;
|
||||
[SerializeField] private float _groundedGravity = -2f;
|
||||
|
||||
public CharacterController CharacterController { get; private set; }
|
||||
|
||||
public bool IsMoving { get; private set; }
|
||||
|
||||
private float _ySpeed;
|
||||
private Vector3 _moveDirection;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
CharacterController = GetComponent<CharacterController>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!PlayerInputManager.IsInputEnabled || CharacterController == null)
|
||||
return;
|
||||
|
||||
HandleMovement();
|
||||
}
|
||||
|
||||
private void HandleMovement()
|
||||
{
|
||||
Vector2 input = PlayerInputManager.Instance.MoveInput;
|
||||
|
||||
Vector3 forward = transform.forward * input.y;
|
||||
Vector3 right = transform.right * input.x;
|
||||
|
||||
_moveDirection = forward + right;
|
||||
_moveDirection = _moveDirection.normalized * _moveSpeed;
|
||||
|
||||
IsMoving = _moveDirection.magnitude > 0.1f && CharacterController.isGrounded;
|
||||
|
||||
if (CharacterController.isGrounded)
|
||||
{
|
||||
_ySpeed = _groundedGravity;
|
||||
|
||||
if (PlayerInputManager.Instance.JumpAction.action.WasPressedThisFrame())
|
||||
_ySpeed = _jumpForce;
|
||||
}
|
||||
else
|
||||
_ySpeed += Physics.gravity.y * Time.deltaTime;
|
||||
|
||||
_moveDirection.y = _ySpeed;
|
||||
CharacterController.Move(_moveDirection * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a25b5737428861f4f8460e0ec5c93015
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 391e04aa0b0405147bbca7f6bde01ea6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,734 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.Utility;
|
||||
|
||||
namespace Unheimlich.Gameplay.Portals
|
||||
{
|
||||
/// <summary>
|
||||
/// Core component of the seamless portal system.
|
||||
/// Creates a real-time view through to a linked portal and handles object teleportation.
|
||||
///
|
||||
/// How it works:
|
||||
/// 1. Renders the view from the linked portal's perspective to a texture
|
||||
/// 2. Displays that texture on this portal's screen mesh
|
||||
/// 3. Tracks objects (PortalTravellers) that enter the portal
|
||||
/// 4. Teleports them when they cross the portal plane
|
||||
/// 5. Uses shader-based slicing to prevent visual artifacts during crossing
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(BoxCollider))]
|
||||
public class Portal : MonoBehaviour
|
||||
{
|
||||
/// <summary>Global list of all active portals in the scene</summary>
|
||||
public static List<Portal> AllPortals = new List<Portal>();
|
||||
|
||||
[Header("Main Settings")]
|
||||
[SerializeField] private string _portalId = "";
|
||||
[SerializeField] private Portal _linkedPortal;
|
||||
[SerializeField] private MeshRenderer _screen;
|
||||
[SerializeField] private int _recursionLimit = 3;
|
||||
|
||||
[Header("Advanced Settings")]
|
||||
[SerializeField] private float _nearClipOffset = 0.05f;
|
||||
[SerializeField] private float _nearClipLimit = 0.2f;
|
||||
[SerializeField] private float _transitionDistance = 0.5f;
|
||||
[SerializeField] private float _loadRoomDistance = 30f;
|
||||
|
||||
[Header("Room Loading")]
|
||||
[SerializeField] private GameObject _roomPrefabToLoad;
|
||||
[SerializeField] private Transform _roomSpawnPoint;
|
||||
[SerializeField] private string _linkedPortalPathInRoom = "Portal";
|
||||
[SerializeField] private string _linkedPortalId = "";
|
||||
|
||||
private RenderTexture _viewTexture;
|
||||
private Camera _portalCam;
|
||||
private Camera _playerCam;
|
||||
private List<PortalTraveller> _trackedTravellers;
|
||||
private MeshFilter _screenMeshFilter;
|
||||
private GameObject _loadedRoomInstance;
|
||||
private string _roomId;
|
||||
private PortalDoor _door;
|
||||
private int _lastRenderedFrame = -1;
|
||||
private bool _playerIsTeleporting = false;
|
||||
|
||||
public event Action RoomLoaded;
|
||||
public event Action RoomUnloaded;
|
||||
|
||||
public Portal LinkedPortal => _linkedPortal;
|
||||
public GameObject LoadedRoomInstance => _loadedRoomInstance;
|
||||
public bool IsDoorOpen => _door == null || _door.IsOpen;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_playerCam = Camera.main;
|
||||
_portalCam = GetComponentInChildren<Camera>();
|
||||
|
||||
// Create portal camera if one doesn't exist
|
||||
if (_portalCam == null)
|
||||
{
|
||||
GameObject camObj = new GameObject(PortalConstants.PORTAL_CAMERA_NAME);
|
||||
camObj.transform.SetParent(transform);
|
||||
camObj.transform.localPosition = Vector3.zero;
|
||||
camObj.transform.localRotation = Quaternion.identity;
|
||||
_portalCam = camObj.AddComponent<Camera>();
|
||||
}
|
||||
|
||||
// Configure portal camera to match player camera settings
|
||||
_portalCam.enabled = false;
|
||||
_portalCam.depth = _playerCam.depth - 1;
|
||||
_portalCam.clearFlags = _playerCam.clearFlags;
|
||||
_portalCam.backgroundColor = _playerCam.backgroundColor;
|
||||
_portalCam.farClipPlane = _playerCam.farClipPlane;
|
||||
_portalCam.nearClipPlane = _playerCam.nearClipPlane;
|
||||
_portalCam.fieldOfView = _playerCam.fieldOfView;
|
||||
_portalCam.cullingMask = _playerCam.cullingMask;
|
||||
_portalCam.allowHDR = _playerCam.allowHDR;
|
||||
_portalCam.allowMSAA = _playerCam.allowMSAA;
|
||||
_portalCam.useOcclusionCulling = _playerCam.useOcclusionCulling;
|
||||
|
||||
// Configure URP camera data for shadows and better quality
|
||||
var urpCamData = _portalCam.GetUniversalAdditionalCameraData();
|
||||
if (urpCamData != null)
|
||||
{
|
||||
// Copy main camera URP settings
|
||||
var mainCamData = _playerCam.GetUniversalAdditionalCameraData();
|
||||
if (mainCamData != null)
|
||||
{
|
||||
urpCamData.renderType = CameraRenderType.Base;
|
||||
urpCamData.renderShadows = mainCamData.renderShadows;
|
||||
urpCamData.requiresDepthOption = mainCamData.requiresDepthOption;
|
||||
urpCamData.requiresColorOption = mainCamData.requiresColorOption;
|
||||
urpCamData.requiresDepthTexture = mainCamData.requiresDepthTexture;
|
||||
urpCamData.requiresColorTexture = mainCamData.requiresColorTexture;
|
||||
urpCamData.renderPostProcessing = false;
|
||||
urpCamData.antialiasing = mainCamData.antialiasing;
|
||||
urpCamData.antialiasingQuality = mainCamData.antialiasingQuality;
|
||||
urpCamData.volumeLayerMask = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if main camera doesn't have URP data
|
||||
urpCamData.renderType = CameraRenderType.Base;
|
||||
urpCamData.renderShadows = true;
|
||||
urpCamData.requiresDepthTexture = true;
|
||||
urpCamData.renderPostProcessing = false;
|
||||
urpCamData.volumeLayerMask = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_trackedTravellers = new List<PortalTraveller>();
|
||||
_screenMeshFilter = _screen.GetComponent<MeshFilter>();
|
||||
_screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
|
||||
|
||||
_door = GetComponentInChildren<PortalDoor>();
|
||||
|
||||
if (_roomPrefabToLoad != null)
|
||||
{
|
||||
_roomId = $"{gameObject.name}_{_roomPrefabToLoad.name}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (!AllPortals.Contains(this))
|
||||
AllPortals.Add(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (AllPortals.Contains(this))
|
||||
AllPortals.Remove(this);
|
||||
}
|
||||
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
|
||||
{
|
||||
PortalRoomManager.Instance.RegisterPortal(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
|
||||
{
|
||||
PortalRoomManager.Instance.UnregisterPortal(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
TryFindLinkedPortal();
|
||||
HandleTravellers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find and link to a portal in a dynamically loaded room.
|
||||
/// Called each frame until a link is established.
|
||||
/// </summary>
|
||||
private void TryFindLinkedPortal()
|
||||
{
|
||||
if (_linkedPortal != null || PortalRoomManager.Instance == null || _roomPrefabToLoad == null)
|
||||
return;
|
||||
|
||||
GameObject loadedRoom = PortalRoomManager.Instance.GetLoadedRoom(_roomId);
|
||||
if (loadedRoom != null)
|
||||
{
|
||||
_loadedRoomInstance = loadedRoom;
|
||||
FindLinkedPortalInRoom(loadedRoom);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for the linked portal within a loaded room.
|
||||
/// First tries to find by portal ID, then falls back to path-based search.
|
||||
/// </summary>
|
||||
private void FindLinkedPortalInRoom(GameObject room)
|
||||
{
|
||||
Portal foundPortal = null;
|
||||
|
||||
// Try to find portal by ID first
|
||||
if (!string.IsNullOrEmpty(_linkedPortalId))
|
||||
{
|
||||
Portal[] portalsInRoom = room.GetComponentsInChildren<Portal>();
|
||||
foreach (Portal portal in portalsInRoom)
|
||||
{
|
||||
if (portal._portalId == _linkedPortalId)
|
||||
{
|
||||
foundPortal = portal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPortal == null)
|
||||
{
|
||||
Debug.LogWarning($"[{name}] Could not find portal with ID '{_linkedPortalId}' in room '{room.name}'. Falling back to path search.");
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to path-based search
|
||||
if (foundPortal == null && !string.IsNullOrEmpty(_linkedPortalPathInRoom))
|
||||
{
|
||||
Transform portalTransform = room.transform.Find(_linkedPortalPathInRoom);
|
||||
if (portalTransform != null)
|
||||
{
|
||||
foundPortal = portalTransform.GetComponent<Portal>();
|
||||
}
|
||||
}
|
||||
|
||||
// Establish bidirectional link
|
||||
if (foundPortal != null)
|
||||
{
|
||||
_linkedPortal = foundPortal;
|
||||
_linkedPortal.SetLinkedPortal(this);
|
||||
|
||||
if (_viewTexture != null && _linkedPortal._screen != null)
|
||||
{
|
||||
_linkedPortal._screen.material.SetTexture(PortalConstants.SHADER_PROPERTY_MAIN_TEXTURE, _viewTexture);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"[{name}] Could not find any portal to link in room '{room.name}'!");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks all travellers in the portal and teleports them when they cross the portal plane.
|
||||
///
|
||||
/// How crossing detection works:
|
||||
/// 1. Calculate which side of the portal the traveller is on using dot product
|
||||
/// 2. Compare with previous frame's side
|
||||
/// 3. If side changed = traveller crossed the portal plane
|
||||
/// 4. Transform traveller's position/rotation through portal transformation matrix
|
||||
/// 5. Teleport to the linked portal
|
||||
/// </summary>
|
||||
private void HandleTravellers()
|
||||
{
|
||||
if (_linkedPortal == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _trackedTravellers.Count; i++)
|
||||
{
|
||||
PortalTraveller traveller = _trackedTravellers[i];
|
||||
Transform travellerT = traveller.transform;
|
||||
|
||||
// Calculate transformation matrix: from this portal's space to linked portal's space
|
||||
var m = _linkedPortal.transform.localToWorldMatrix * transform.worldToLocalMatrix * travellerT.localToWorldMatrix;
|
||||
|
||||
// Determine which side of portal the traveller is on
|
||||
Vector3 offsetFromPortal = travellerT.position - transform.position;
|
||||
int portalSide = Math.Sign(Vector3.Dot(offsetFromPortal, transform.forward));
|
||||
int portalSideOld = Math.Sign(Vector3.Dot(traveller.PreviousOffsetFromPortal, transform.forward));
|
||||
|
||||
// If side changed, traveller has crossed the portal
|
||||
if (portalSide != portalSideOld)
|
||||
{
|
||||
if (traveller.CompareTag("Player"))
|
||||
{
|
||||
_playerIsTeleporting = true;
|
||||
_linkedPortal._playerIsTeleporting = true;
|
||||
}
|
||||
|
||||
// Hide both original and clone during teleport
|
||||
traveller.SetGraphicsVisibility(false);
|
||||
traveller.SetCloneVisibility(false);
|
||||
|
||||
// Teleport with transformed position and rotation
|
||||
traveller.Teleport(transform, _linkedPortal.transform, m.GetColumn(3), m.rotation);
|
||||
|
||||
Physics.SyncTransforms();
|
||||
|
||||
// Show traveller at new location
|
||||
traveller.SetGraphicsVisibility(true);
|
||||
|
||||
// Transfer tracking to the linked portal
|
||||
_linkedPortal.OnTravellerEnterPortal(traveller);
|
||||
_trackedTravellers.RemoveAt(i);
|
||||
i--;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update clone position to match transformed position
|
||||
traveller.GraphicsClone.transform.SetPositionAndRotation(m.GetColumn(3), m.rotation);
|
||||
traveller.PreviousOffsetFromPortal = offsetFromPortal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before rendering. Updates shader slicing parameters for all tracked travellers.
|
||||
/// </summary>
|
||||
public void PrePortalRender()
|
||||
{
|
||||
if (_linkedPortal == null)
|
||||
return;
|
||||
|
||||
foreach (var traveller in _trackedTravellers)
|
||||
{
|
||||
UpdateSliceParams(traveller);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main portal rendering function.
|
||||
/// Creates a virtual camera at the linked portal's position and renders the view to a texture.
|
||||
/// Supports recursive rendering (portal looking at portal looking at portal...).
|
||||
/// </summary>
|
||||
public void Render()
|
||||
{
|
||||
// Prevent rendering same portal multiple times per frame
|
||||
if (_lastRenderedFrame == Time.frameCount)
|
||||
return;
|
||||
|
||||
_lastRenderedFrame = Time.frameCount;
|
||||
|
||||
// Special case: player is teleporting, render without screen
|
||||
if (_playerIsTeleporting)
|
||||
{
|
||||
_screen.enabled = false;
|
||||
_portalCam.Render();
|
||||
_screen.enabled = true;
|
||||
_playerIsTeleporting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_linkedPortal == null)
|
||||
{
|
||||
if (_roomPrefabToLoad != null)
|
||||
{
|
||||
// Silently skip rendering while waiting for room to load
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_linkedPortal == null || _linkedPortal._screen == null)
|
||||
{
|
||||
if (_roomPrefabToLoad != null)
|
||||
{
|
||||
// Silently skip rendering while waiting for linked portal
|
||||
return;
|
||||
}
|
||||
Debug.LogError($"[{name}] Cannot render: linked portal is null or has NULL _screen!");
|
||||
return;
|
||||
}
|
||||
|
||||
float distanceToCamera = Vector3.Distance(_playerCam.transform.position, transform.position);
|
||||
|
||||
Vector3 offsetFromPortal = _playerCam.transform.position - transform.position;
|
||||
float dotToPortal = Vector3.Dot(offsetFromPortal.normalized, transform.forward);
|
||||
|
||||
// Don't render screen when player is crossing through
|
||||
bool playerCrossingPortal = distanceToCamera < 0.5f && dotToPortal > 0.5f;
|
||||
|
||||
if (playerCrossingPortal)
|
||||
{
|
||||
_screen.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't render during transition to avoid flickering
|
||||
if (distanceToCamera < _transitionDistance && dotToPortal > 0.5f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Frustum culling: don't render if portal screen not visible
|
||||
if (!PortalCameraUtility.VisibleFromCamera(_linkedPortal._screen, _playerCam))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CreateViewTexture();
|
||||
|
||||
if (!_viewTexture.IsCreated())
|
||||
{
|
||||
_viewTexture.Create();
|
||||
}
|
||||
|
||||
// Calculate recursive portal positions (for portal-in-portal effect)
|
||||
var localToWorldMatrix = _playerCam.transform.localToWorldMatrix;
|
||||
var renderPositions = new Vector3[_recursionLimit];
|
||||
var renderRotations = new Quaternion[_recursionLimit];
|
||||
|
||||
int startIndex = 0;
|
||||
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
|
||||
|
||||
// Calculate transform matrices for each recursion level
|
||||
for (int i = 0; i < _recursionLimit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
// Stop recursion if screens don't overlap
|
||||
if (!PortalCameraUtility.BoundsOverlap(_screenMeshFilter, _linkedPortal._screenMeshFilter, _portalCam))
|
||||
break;
|
||||
}
|
||||
|
||||
// Transform camera through portal pair
|
||||
localToWorldMatrix = transform.localToWorldMatrix * _linkedPortal.transform.worldToLocalMatrix * localToWorldMatrix;
|
||||
int renderOrderIndex = _recursionLimit - i - 1;
|
||||
renderPositions[renderOrderIndex] = localToWorldMatrix.GetColumn(3);
|
||||
renderRotations[renderOrderIndex] = localToWorldMatrix.rotation;
|
||||
|
||||
_portalCam.transform.SetPositionAndRotation(renderPositions[renderOrderIndex], renderRotations[renderOrderIndex]);
|
||||
startIndex = renderOrderIndex;
|
||||
}
|
||||
|
||||
_screen.enabled = false; // Hide current portal screen so camera can see through
|
||||
|
||||
// Render from each recursion depth
|
||||
for (int i = startIndex; i < _recursionLimit; i++)
|
||||
{
|
||||
_portalCam.transform.SetPositionAndRotation(renderPositions[i], renderRotations[i]);
|
||||
SetNearClipPlane();
|
||||
HandleClipping();
|
||||
|
||||
_linkedPortal._screen.enabled = false;
|
||||
_portalCam.Render();
|
||||
_linkedPortal._screen.enabled = true;
|
||||
}
|
||||
|
||||
_screen.enabled = true;
|
||||
|
||||
_linkedPortal._screen.material.mainTexture = _viewTexture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles shader-based clipping for travellers during rendering.
|
||||
/// Determines which parts of original and clone should be visible based on portal positions.
|
||||
/// Prevents visual artifacts when objects cross portal boundaries.
|
||||
/// </summary>
|
||||
private void HandleClipping()
|
||||
{
|
||||
const float hideDst = -1000;
|
||||
const float showDst = 1000;
|
||||
float screenThickness = _linkedPortal.ProtectScreenFromClipping(_portalCam.transform.position);
|
||||
|
||||
// Handle clipping for this portal's travellers
|
||||
foreach (var traveller in _trackedTravellers)
|
||||
{
|
||||
// Original: hide if on same side as portal camera
|
||||
if (SameSideOfPortal(traveller.transform.position, PortalCamPos))
|
||||
traveller.SetSliceOffsetDst(hideDst, false);
|
||||
else
|
||||
traveller.SetSliceOffsetDst(showDst, false);
|
||||
|
||||
// Clone: adjust based on linked portal position
|
||||
int cloneSideOfLinkedPortal = -SideOfPortal(traveller.transform.position);
|
||||
bool camSameSideAsClone = _linkedPortal.SideOfPortal(PortalCamPos) == cloneSideOfLinkedPortal;
|
||||
if (camSameSideAsClone)
|
||||
traveller.SetSliceOffsetDst(screenThickness, true);
|
||||
else
|
||||
traveller.SetSliceOffsetDst(-screenThickness, true);
|
||||
}
|
||||
|
||||
// Handle clipping for linked portal's travellers
|
||||
foreach (var linkedTraveller in _linkedPortal._trackedTravellers)
|
||||
{
|
||||
var travellerPos = linkedTraveller.GraphicsObject.transform.position;
|
||||
bool cloneOnSameSideAsCam = _linkedPortal.SideOfPortal(travellerPos) != SideOfPortal(PortalCamPos);
|
||||
|
||||
if (cloneOnSameSideAsCam)
|
||||
linkedTraveller.SetSliceOffsetDst(hideDst, true);
|
||||
else
|
||||
linkedTraveller.SetSliceOffsetDst(showDst, true);
|
||||
|
||||
bool camSameSideAsTraveller = _linkedPortal.SameSideOfPortal(linkedTraveller.transform.position, PortalCamPos);
|
||||
if (camSameSideAsTraveller)
|
||||
linkedTraveller.SetSliceOffsetDst(screenThickness, false);
|
||||
else
|
||||
linkedTraveller.SetSliceOffsetDst(-screenThickness, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after all portals have rendered. Resets clipping parameters.
|
||||
/// </summary>
|
||||
public void PostPortalRender()
|
||||
{
|
||||
foreach (var traveller in _trackedTravellers)
|
||||
UpdateSliceParams(traveller);
|
||||
|
||||
ProtectScreenFromClipping(_portalCam.transform.position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or recreates the render texture used to display the portal view.
|
||||
/// Automatically adjusts to screen resolution changes.
|
||||
/// </summary>
|
||||
private void CreateViewTexture()
|
||||
{
|
||||
if (_viewTexture == null || _viewTexture.width != Screen.width || _viewTexture.height != Screen.height)
|
||||
{
|
||||
if (_viewTexture != null)
|
||||
_viewTexture.Release();
|
||||
|
||||
// Use DefaultHDR for better shadow and lighting compatibility in URP
|
||||
_viewTexture = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.DefaultHDR);
|
||||
_viewTexture.filterMode = FilterMode.Bilinear;
|
||||
_viewTexture.antiAliasing = 1;
|
||||
_viewTexture.Create();
|
||||
|
||||
_portalCam.targetTexture = _viewTexture;
|
||||
_linkedPortal._screen.material.mainTexture = _viewTexture;
|
||||
_linkedPortal._screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts the portal screen's thickness to prevent camera near-clip plane from cutting through it.
|
||||
/// The thickness is calculated based on the camera's near clip distance and FOV.
|
||||
/// Returns the calculated thickness for use in clipping calculations.
|
||||
/// </summary>
|
||||
private float ProtectScreenFromClipping(Vector3 viewPoint)
|
||||
{
|
||||
float halfHeight = _playerCam.nearClipPlane * Mathf.Tan(_playerCam.fieldOfView * 0.5f * Mathf.Deg2Rad);
|
||||
float halfWidth = halfHeight * _playerCam.aspect;
|
||||
float dstToNearClipPlaneCorner = new Vector3(halfWidth, halfHeight, _playerCam.nearClipPlane).magnitude;
|
||||
float screenThickness = dstToNearClipPlaneCorner;
|
||||
|
||||
Transform screenT = _screen.transform;
|
||||
bool camFacingSameDirAsPortal = Vector3.Dot(transform.forward, transform.position - viewPoint) > 0;
|
||||
screenT.localScale = new Vector3(screenT.localScale.x, screenT.localScale.y, screenThickness);
|
||||
return screenThickness;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates shader parameters for slicing a traveller's graphics at the portal plane.
|
||||
/// Sets the slice position, normal, and offset for both original and clone materials.
|
||||
/// This creates the effect of objects being "cut" by the portal surface.
|
||||
/// </summary>
|
||||
private void UpdateSliceParams(PortalTraveller traveller)
|
||||
{
|
||||
int side = SideOfPortal(traveller.transform.position);
|
||||
Vector3 sliceNormal = transform.forward * -side;
|
||||
Vector3 cloneSliceNormal = _linkedPortal.transform.forward * side;
|
||||
|
||||
Vector3 slicePos = transform.position;
|
||||
Vector3 cloneSlicePos = _linkedPortal.transform.position;
|
||||
|
||||
float sliceOffsetDst = 0;
|
||||
float cloneSliceOffsetDst = 0;
|
||||
float screenThickness = _screen.transform.localScale.z;
|
||||
|
||||
// Adjust slicing based on player and traveller relative positions
|
||||
bool playerSameSideAsTraveller = SameSideOfPortal(_playerCam.transform.position, traveller.transform.position);
|
||||
if (!playerSameSideAsTraveller)
|
||||
sliceOffsetDst = -screenThickness;
|
||||
|
||||
bool playerSameSideAsCloneAppearing = side != _linkedPortal.SideOfPortal(_playerCam.transform.position);
|
||||
if (!playerSameSideAsCloneAppearing)
|
||||
cloneSliceOffsetDst = -screenThickness;
|
||||
|
||||
// Apply slicing parameters to all materials
|
||||
for (int i = 0; i < traveller.OriginalMaterials.Length; i++)
|
||||
{
|
||||
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, slicePos);
|
||||
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, sliceNormal);
|
||||
traveller.OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, sliceOffsetDst);
|
||||
|
||||
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, cloneSlicePos);
|
||||
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, cloneSliceNormal);
|
||||
traveller.CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, cloneSliceOffsetDst);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up an oblique projection matrix to use the portal surface as the near clip plane.
|
||||
/// This prevents objects between the camera and portal from being rendered.
|
||||
/// Uses camera-space calculations to create a custom projection matrix.
|
||||
/// </summary>
|
||||
private void SetNearClipPlane()
|
||||
{
|
||||
Transform clipPlane = transform;
|
||||
int dot = Math.Sign(Vector3.Dot(clipPlane.forward, transform.position - _portalCam.transform.position));
|
||||
|
||||
// Transform portal plane to camera space
|
||||
Vector3 camSpacePos = _portalCam.worldToCameraMatrix.MultiplyPoint(clipPlane.position);
|
||||
Vector3 camSpaceNormal = _portalCam.worldToCameraMatrix.MultiplyVector(clipPlane.forward) * dot;
|
||||
float camSpaceDst = -Vector3.Dot(camSpacePos, camSpaceNormal) + _nearClipOffset;
|
||||
|
||||
// Only use oblique projection if far enough from portal
|
||||
if (Mathf.Abs(camSpaceDst) > _nearClipLimit)
|
||||
{
|
||||
Vector4 clipPlaneCameraSpace = new Vector4(camSpaceNormal.x, camSpaceNormal.y, camSpaceNormal.z, camSpaceDst);
|
||||
_portalCam.projectionMatrix = _playerCam.CalculateObliqueMatrix(clipPlaneCameraSpace);
|
||||
}
|
||||
else
|
||||
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a traveller enters this portal's trigger zone.
|
||||
/// Initializes tracking and creates the visual clone.
|
||||
/// </summary>
|
||||
private void OnTravellerEnterPortal(PortalTraveller traveller)
|
||||
{
|
||||
if (!_trackedTravellers.Contains(traveller))
|
||||
{
|
||||
traveller.EnterPortalThreshold();
|
||||
traveller.PreviousOffsetFromPortal = traveller.transform.position - transform.position;
|
||||
_trackedTravellers.Add(traveller);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity physics callback when object enters portal's trigger collider.
|
||||
/// </summary>
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
var traveller = other.GetComponent<PortalTraveller>();
|
||||
if (traveller)
|
||||
OnTravellerEnterPortal(traveller);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity physics callback when object exits portal's trigger collider.
|
||||
/// Cleans up the clone if traveller exits without teleporting.
|
||||
/// </summary>
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
var traveller = other.GetComponent<PortalTraveller>();
|
||||
if (traveller && _trackedTravellers.Contains(traveller))
|
||||
{
|
||||
traveller.ExitPortalThreshold();
|
||||
_trackedTravellers.Remove(traveller);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLinkedPortal(Portal portal)
|
||||
{
|
||||
_linkedPortal = portal;
|
||||
}
|
||||
|
||||
public GameObject GetRoomPrefab() => _roomPrefabToLoad;
|
||||
public string GetRoomId() => _roomId;
|
||||
public string GetLinkedPortalPath() => _linkedPortalPathInRoom;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the world position where the room should be spawned.
|
||||
/// Uses room spawn point if set, otherwise calculates based on portal position.
|
||||
/// </summary>
|
||||
public Vector3 GetRoomSpawnPosition()
|
||||
{
|
||||
if (_roomSpawnPoint != null)
|
||||
return _roomSpawnPoint.position;
|
||||
return transform.position + (transform.up + transform.right).normalized * _loadRoomDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the rotation for spawning the room.
|
||||
/// </summary>
|
||||
public Quaternion GetRoomSpawnRotation()
|
||||
{
|
||||
if (_roomSpawnPoint != null)
|
||||
return _roomSpawnPoint.rotation;
|
||||
return transform.rotation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by PortalRoomManager when the associated room is loaded.
|
||||
/// </summary>
|
||||
public void OnRoomLoaded(GameObject room)
|
||||
{
|
||||
_loadedRoomInstance = room;
|
||||
RoomLoaded?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by PortalRoomManager when the associated room is unloaded.
|
||||
/// Clears the linked portal reference.
|
||||
/// </summary>
|
||||
public void OnRoomUnloaded()
|
||||
{
|
||||
RoomUnloaded?.Invoke();
|
||||
_loadedRoomInstance = null;
|
||||
if (_roomPrefabToLoad != null)
|
||||
_linkedPortal = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which side of the portal a position is on.
|
||||
/// Returns +1 if in front (same direction as portal forward), -1 if behind.
|
||||
/// Uses dot product to compare position relative to portal plane.
|
||||
/// </summary>
|
||||
public int SideOfPortal(Vector3 pos)
|
||||
{
|
||||
return Math.Sign(Vector3.Dot(pos - transform.position, transform.forward));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two positions are on the same side of the portal.
|
||||
/// </summary>
|
||||
public bool SameSideOfPortal(Vector3 posA, Vector3 posB)
|
||||
{
|
||||
return SideOfPortal(posA) == SideOfPortal(posB);
|
||||
}
|
||||
|
||||
public Portal GetLinkedPortal() => _linkedPortal;
|
||||
|
||||
public string GetPortalId() => _portalId;
|
||||
|
||||
public string GetLinkedPortalId() => _linkedPortalId;
|
||||
|
||||
public PortalDoor GetDoor() => _door;
|
||||
|
||||
public float GetRoomLoadDistance() => _loadRoomDistance;
|
||||
|
||||
private Vector3 PortalCamPos => _portalCam.transform.position;
|
||||
|
||||
/// <summary>
|
||||
/// Unity editor callback to maintain bidirectional portal links in the inspector.
|
||||
/// </summary>
|
||||
private void OnValidate()
|
||||
{
|
||||
if (_linkedPortal != null)
|
||||
_linkedPortal._linkedPortal = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5bee016a7303e2c458585f5cc66817e2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 200
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Environment;
|
||||
|
||||
namespace Unheimlich.Gameplay.Portals
|
||||
{
|
||||
/// <summary>
|
||||
/// A door that is synchronized between linked portals.
|
||||
/// When opened/closed, it automatically syncs the state with the door on the other side.
|
||||
/// </summary>
|
||||
public class PortalDoor : DoorInteractable
|
||||
{
|
||||
private Portal _parentPortal;
|
||||
|
||||
protected override void Start()
|
||||
{
|
||||
base.Start();
|
||||
_parentPortal = GetComponentInParent<Portal>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles interaction with the door.
|
||||
/// Toggles both this door and the linked portal's door simultaneously.
|
||||
/// </summary>
|
||||
public override void Interact(GameObject interactor)
|
||||
{
|
||||
SyncToggle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles this door and synchronizes the state with the linked portal's door.
|
||||
/// Ensures both doors open/close together for a consistent experience.
|
||||
/// </summary>
|
||||
private void SyncToggle()
|
||||
{
|
||||
ToggleDoor();
|
||||
|
||||
if (_parentPortal != null && _parentPortal.GetLinkedPortal() != null)
|
||||
{
|
||||
PortalDoor linkedDoor = _parentPortal.GetLinkedPortal().GetDoor();
|
||||
if (linkedDoor != null)
|
||||
linkedDoor.ToggleDoor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f96b3c121a38b8e4784628da571e6c5b
|
||||
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Unheimlich.Gameplay.Portals
|
||||
{
|
||||
/// <summary>
|
||||
/// Main orchestrator for the portal rendering system.
|
||||
/// Attach this to the main camera to enable seamless portal rendering.
|
||||
/// Manages the render pipeline for all active portals in the scene.
|
||||
///
|
||||
/// URP-compatible version using RenderPipelineManager callbacks.
|
||||
/// </summary>
|
||||
public class PortalMainCamera : MonoBehaviour
|
||||
{
|
||||
private Camera _mainCamera;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_mainCamera = GetComponent<Camera>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before each camera renders in URP pipeline.
|
||||
/// This is where we inject the portal rendering logic before the main camera renders.
|
||||
/// </summary>
|
||||
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
|
||||
{
|
||||
// Only render portals for the main camera, not for portal cameras themselves
|
||||
if (camera != _mainCamera)
|
||||
return;
|
||||
|
||||
// Phase 1: Prepare all portals for rendering (update shader clipping parameters)
|
||||
for (int i = 0; i < Portal.AllPortals.Count; i++)
|
||||
{
|
||||
var p = Portal.AllPortals[i];
|
||||
if (p != null && p.isActiveAndEnabled)
|
||||
p.PrePortalRender();
|
||||
}
|
||||
|
||||
// Phase 2: Render all portals (create the view through each portal)
|
||||
for (int i = 0; i < Portal.AllPortals.Count; i++)
|
||||
{
|
||||
var p = Portal.AllPortals[i];
|
||||
if (p != null && p.isActiveAndEnabled)
|
||||
p.Render();
|
||||
}
|
||||
|
||||
// Phase 3: Clean up after portal rendering
|
||||
for (int i = 0; i < Portal.AllPortals.Count; i++)
|
||||
{
|
||||
var p = Portal.AllPortals[i];
|
||||
if (p != null && p.isActiveAndEnabled)
|
||||
p.PostPortalRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98eb193612a097646bb0d333bb4aae51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 300
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Gameplay.Portals
|
||||
{
|
||||
/// <summary>
|
||||
/// Component that allows an object to travel through portals.
|
||||
/// Handles teleportation, visual cloning when crossing, and shader-based slicing.
|
||||
/// Attach this to any object that should be able to pass through portals (player, NPCs, etc.)
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public class PortalTraveller : MonoBehaviour
|
||||
{
|
||||
/// <summary>The visual representation of this traveller (mesh renderers)</summary>
|
||||
public GameObject GraphicsObject;
|
||||
|
||||
/// <summary>Clone created when traveller enters portal threshold</summary>
|
||||
public GameObject GraphicsClone { get; set; }
|
||||
|
||||
/// <summary>Tracks position relative to portal from previous frame to detect crossing</summary>
|
||||
public Vector3 PreviousOffsetFromPortal { get; set; }
|
||||
|
||||
/// <summary>Materials of the original graphics (used for shader slicing)</summary>
|
||||
public Material[] OriginalMaterials { get; set; }
|
||||
|
||||
/// <summary>Materials of the clone (used for shader slicing)</summary>
|
||||
public Material[] CloneMaterials { get; set; }
|
||||
|
||||
private CharacterController _characterController;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_characterController = GetComponent<CharacterController>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teleports this traveller through a portal.
|
||||
/// Temporarily disables CharacterController to prevent physics conflicts during teleport.
|
||||
/// </summary>
|
||||
/// <param name="fromPortal">The portal being exited</param>
|
||||
/// <param name="toPortal">The destination portal</param>
|
||||
/// <param name="pos">New world position</param>
|
||||
/// <param name="rot">New world rotation</param>
|
||||
public virtual void Teleport(Transform fromPortal, Transform toPortal, Vector3 pos, Quaternion rot)
|
||||
{
|
||||
if (_characterController != null)
|
||||
_characterController.enabled = false;
|
||||
|
||||
transform.position = pos;
|
||||
transform.rotation = rot;
|
||||
|
||||
if (_characterController != null)
|
||||
{
|
||||
_characterController.enabled = true;
|
||||
Physics.SyncTransforms();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the traveller enters the portal's trigger zone.
|
||||
/// Creates a visual clone that will appear on the other side of the portal.
|
||||
/// </summary>
|
||||
public virtual void EnterPortalThreshold()
|
||||
{
|
||||
if (GraphicsClone == null)
|
||||
{
|
||||
GraphicsClone = Instantiate(GraphicsObject);
|
||||
GraphicsClone.transform.parent = GraphicsObject.transform.parent;
|
||||
GraphicsClone.transform.localScale = GraphicsObject.transform.localScale;
|
||||
OriginalMaterials = GetMaterials(GraphicsObject);
|
||||
CloneMaterials = GetMaterials(GraphicsClone);
|
||||
}
|
||||
else
|
||||
GraphicsClone.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the traveller exits the portal's trigger zone without teleporting.
|
||||
/// Hides the clone and resets shader slicing parameters.
|
||||
/// </summary>
|
||||
public virtual void ExitPortalThreshold()
|
||||
{
|
||||
GraphicsClone.SetActive(false);
|
||||
|
||||
for (int i = 0; i < OriginalMaterials.Length; i++)
|
||||
OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, Vector3.zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the slice offset distance for shader-based clipping.
|
||||
/// Used to hide/show parts of the traveller based on portal plane position.
|
||||
/// </summary>
|
||||
/// <param name="dst">Distance offset for slicing plane</param>
|
||||
/// <param name="clone">If true, applies to clone materials; otherwise to original</param>
|
||||
public void SetSliceOffsetDst(float dst, bool clone)
|
||||
{
|
||||
for (int i = 0; i < OriginalMaterials.Length; i++)
|
||||
{
|
||||
if (clone)
|
||||
CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
|
||||
else
|
||||
OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects all materials from MeshRenderers in the graphics object hierarchy.
|
||||
/// These materials will be modified with shader slicing parameters.
|
||||
/// </summary>
|
||||
private Material[] GetMaterials(GameObject g)
|
||||
{
|
||||
var renderers = g.GetComponentsInChildren<MeshRenderer>();
|
||||
var matList = new List<Material>();
|
||||
|
||||
foreach (var renderer in renderers)
|
||||
{
|
||||
foreach (var mat in renderer.materials)
|
||||
matList.Add(mat);
|
||||
}
|
||||
|
||||
return matList.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows or hides the original graphics by enabling/disabling renderers.
|
||||
/// </summary>
|
||||
public void SetGraphicsVisibility(bool visible)
|
||||
{
|
||||
var renderers = GraphicsObject.GetComponentsInChildren<Renderer>();
|
||||
foreach (var renderer in renderers)
|
||||
renderer.enabled = visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows or hides the clone graphics by enabling/disabling renderers.
|
||||
/// </summary>
|
||||
public void SetCloneVisibility(bool visible)
|
||||
{
|
||||
if (GraphicsClone != null)
|
||||
{
|
||||
var renderers = GraphicsClone.GetComponentsInChildren<Renderer>();
|
||||
foreach (var renderer in renderers)
|
||||
renderer.enabled = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e825afa0efeb69949bf3ca8b3f99d7eb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 400
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac0f3dcb3f3202148acfefe36d15ce22
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 188b6b92f90c1d248a52afe96fff07b7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3392c3a642d4da4eb1d6311bc686747
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Unheimlich.Interactions.Dialogues
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializable data structure representing a single dialogue choice option.
|
||||
/// Links choice text to the next dialogue node via ID.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class DialogueChoice
|
||||
{
|
||||
public string ChoiceText; // Text on the choice button
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7a6b90d8597f824a9451b58900227b9
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.UI;
|
||||
|
||||
namespace Unheimlich.Interactions.Dialogues
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b328dc63103543940898cfa2dbd4ec36
|
||||
@@ -0,0 +1,185 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.UI;
|
||||
|
||||
namespace Unheimlich.Interactions.Dialogues
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f33be20afd5d4c843a800ba1d8dd8437
|
||||
@@ -0,0 +1,142 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Interactions.Core;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
using Unheimlich.UI;
|
||||
|
||||
namespace Unheimlich.Interactions.Dialogues
|
||||
{
|
||||
/// <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 && Player.Instance != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, Player.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(GameObject interactor)
|
||||
{
|
||||
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 (Player.Instance == null) return;
|
||||
|
||||
Vector3 lookAtPosition = Player.Instance.transform.position;
|
||||
lookAtPosition.y = transform.position.y;
|
||||
|
||||
transform.LookAt(lookAtPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8696519b7eabaab4dbe80f2a4744cea0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2c6fc4373abfa54fa4ce7c0392875f1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Managers;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
|
||||
namespace Unheimlich.Interactions.FloatingWords
|
||||
{
|
||||
/// <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?.ChoiceText ?? 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 = 150f;
|
||||
[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 bool _positionCaptured = false;
|
||||
|
||||
private Vector3 _shakeOffset = Vector3.zero;
|
||||
private Vector3 _shakeTarget = Vector3.zero;
|
||||
private float _shakeTimer = 0f;
|
||||
private bool _isShaking = false;
|
||||
|
||||
private bool _disableFloatingEffects = false;
|
||||
|
||||
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
|
||||
private float _historyTimer = 0f;
|
||||
private const float HISTORY_SAMPLE_RATE = 0.016f;
|
||||
|
||||
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;
|
||||
|
||||
Canvas ghostCanvas = ghostObj.AddComponent<Canvas>();
|
||||
ghostCanvas.overrideSorting = true;
|
||||
ghostCanvas.sortingOrder = -1;
|
||||
|
||||
_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();
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress);
|
||||
|
||||
bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus();
|
||||
|
||||
if (shouldMoveToPlayer && Player.Instance != null)
|
||||
{
|
||||
float distance = GetFocusDistanceFromPlayer();
|
||||
Vector3 targetPos = Player.Instance.transform.position
|
||||
+ Player.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;
|
||||
|
||||
LeanTween.cancel(gameObject);
|
||||
|
||||
SetupGhostTrail();
|
||||
|
||||
if (_ghostTextComponent != null)
|
||||
_ghostTextComponent.text = GetChoiceText();
|
||||
|
||||
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);
|
||||
StopOrganicShake();
|
||||
|
||||
LeanTween.moveLocal(gameObject, _startLocalPos, duration)
|
||||
.setEase(LeanTweenType.easeOutQuad)
|
||||
.setOnComplete(() => {
|
||||
_positionCaptured = false;
|
||||
|
||||
if (DialogueManager.Instance != null)
|
||||
DialogueManager.Instance.UnlockFocus();
|
||||
|
||||
StartOrganicShake();
|
||||
});
|
||||
|
||||
transform.localScale = Vector3.one;
|
||||
|
||||
if (_choiceTextComponent != null)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abd0f63a44b2272448eb41767c2ce44e
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
|
||||
namespace Unheimlich.Interactions.FloatingWords
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for spawning floating 3D text choices in world space.
|
||||
/// Used for both dialogue choices and object descriptions. Handles positioning,
|
||||
/// rotation, animations (spawn, merge, floating) using LeanTween for smooth effects.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
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 (Player.Instance == null) return words;
|
||||
|
||||
Vector3 playerForward = Player.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 = Player.Instance.transform.position + offset;
|
||||
word.transform.LookAt(Player.Instance.transform);
|
||||
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 (Player.Instance == null) return words;
|
||||
|
||||
parent.position = Player.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(Player.Instance.transform);
|
||||
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)
|
||||
{
|
||||
LeanTween.value(words[i].gameObject, 1f, 0f, config.MergeDuration)
|
||||
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
|
||||
.setEaseInQuad();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
LeanTween.scale(words[0].gameObject, Vector3.one * config.MergeScalePulse, config.MergeDuration / 2f)
|
||||
.setEaseOutBack()
|
||||
.setOnComplete(() =>
|
||||
{
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
LeanTween.scale(words[0].gameObject, Vector3.one, config.MergeDuration / 2f)
|
||||
.setEaseInBack()
|
||||
.setOnComplete(() => onMergeComplete?.Invoke());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
LeanTween.cancel(words[i].gameObject);
|
||||
CanvasGroup canvasGroup = words[i].GetComponent<CanvasGroup>();
|
||||
if (canvasGroup != null)
|
||||
{
|
||||
LeanTween.value(words[i].gameObject, canvasGroup.alpha, 1f, config.MergeDuration)
|
||||
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
|
||||
.setEaseOutQuad();
|
||||
}
|
||||
words[i].transform.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
|
||||
if (words.Count > 0 && words[0] != null)
|
||||
{
|
||||
LeanTween.cancel(words[0].gameObject);
|
||||
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
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b52b115a50463d46b645f5579cf22fd
|
||||
@@ -0,0 +1,87 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Managers;
|
||||
|
||||
namespace Unheimlich.Interactions.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects interactable objects in front of the player camera using raycasts.
|
||||
/// Triggers interaction when player presses interact button while looking at an interactable.
|
||||
/// </summary>
|
||||
public class InteractionHandler : MonoBehaviour
|
||||
{
|
||||
public static InteractionHandler Instance { get; private set; }
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _interactionRange = 1f;
|
||||
[SerializeField] private LayerMask _interactableLayers;
|
||||
[SerializeField] private float _raycastInterval = 0.1f;
|
||||
|
||||
private InteractableBase _currentInteractable;
|
||||
private Camera _playerCamera;
|
||||
private float _raycastTimer;
|
||||
|
||||
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
_playerCamera = GetComponent<Camera>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_raycastTimer += Time.deltaTime;
|
||||
|
||||
if (_raycastTimer >= _raycastInterval)
|
||||
{
|
||||
CheckForInteractable();
|
||||
_raycastTimer = 0f;
|
||||
}
|
||||
|
||||
HandleInteractionInput();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs raycast from camera center to detect interactable objects within range.
|
||||
/// Updates the current interactable reference for input handling.
|
||||
/// </summary>
|
||||
private void CheckForInteractable()
|
||||
{
|
||||
Ray ray = _playerCamera.ViewportPointToRay(ViewportCenter);
|
||||
|
||||
if (Physics.Raycast(ray, out RaycastHit hit, _interactionRange, _interactableLayers))
|
||||
{
|
||||
InteractableBase hitInteractable = hit.collider.GetComponent<InteractableBase>();
|
||||
_currentInteractable = hitInteractable;
|
||||
}
|
||||
else
|
||||
_currentInteractable = null;
|
||||
}
|
||||
|
||||
private void HandleInteractionInput()
|
||||
{
|
||||
if (PlayerInputManager.Instance != null &&
|
||||
PlayerInputManager.Instance.InteractAction.action.WasPressedThisFrame())
|
||||
{
|
||||
if (_currentInteractable != null)
|
||||
_currentInteractable.Interact(transform.parent.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_playerCamera != null)
|
||||
{
|
||||
Gizmos.color = _currentInteractable != null ? Color.green : Color.red;
|
||||
|
||||
Ray ray = _playerCamera.ViewportPointToRay(ViewportCenter);
|
||||
Gizmos.DrawRay(ray.origin, ray.direction * _interactionRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d96ceb0ee513b1e4197e55e9b926269b
|
||||
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Interactions.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all interactable objects in the game.
|
||||
/// Derived classes must implement Interact() to define interaction behavior.
|
||||
/// </summary>
|
||||
public abstract class InteractableBase : MonoBehaviour
|
||||
{
|
||||
[Header("Interaction Settings")]
|
||||
[Tooltip("Text displayed when the player looks at the object")]
|
||||
public string InteractionText = "Interact";
|
||||
|
||||
public abstract void Interact(GameObject gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2bd6cb9667f8674294172efbdf8afd3
|
||||
@@ -0,0 +1,304 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
|
||||
namespace Unheimlich.Interactions.FloatingWords
|
||||
{
|
||||
public class ObjectDescriptionTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("Description")]
|
||||
[SerializeField] private string _descriptionText = "Mysterious Object";
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private ObjectDescriptionSettings _settings;
|
||||
|
||||
[Header("Anchor")]
|
||||
[SerializeField] private Transform _descriptionAnchor;
|
||||
|
||||
[Header("Prefab")]
|
||||
[SerializeField] private ChoiceFocusHandler _wordPrefab;
|
||||
|
||||
private Transform _playerTransform;
|
||||
private Camera _mainCamera;
|
||||
private List<ChoiceFocusHandler> _spawnedWords = new List<ChoiceFocusHandler>();
|
||||
private GameObject _wordsContainer;
|
||||
private GameObject _wordsRotator;
|
||||
private bool _isActive = false;
|
||||
private bool _isMerged = false;
|
||||
private bool _hasBeenMergedOnce = false;
|
||||
private bool _hasCompletedMergeOnce = false;
|
||||
private Vector3[] _originalSpawnPositions;
|
||||
private Vector3[] _basePositions;
|
||||
private float _currentConvergenceRadius;
|
||||
|
||||
public bool IsAnimationActive => _isActive && (!_settings.MergeOnce || !_hasCompletedMergeOnce);
|
||||
public bool IsInMergeOnceMode => _settings != null && _settings.MergeOnce;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_playerTransform = Player.Instance.transform;
|
||||
_mainCamera = Camera.main;
|
||||
|
||||
if (_playerTransform == null)
|
||||
Debug.LogWarning($"[ObjectDescription] No player found for {gameObject.name}");
|
||||
|
||||
if (_descriptionAnchor == null)
|
||||
_descriptionAnchor = transform;
|
||||
|
||||
if (_settings == null)
|
||||
{
|
||||
Debug.LogError($"[ObjectDescription] No settings assigned for {gameObject.name}");
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_playerTransform == null || _settings == null) return;
|
||||
|
||||
float distanceToPlayer = Vector3.Distance(_playerTransform.position, transform.position);
|
||||
|
||||
if (!_isActive && distanceToPlayer <= _settings.ActivationDistance)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasCompletedMergeOnce)
|
||||
SpawnWords();
|
||||
}
|
||||
else if (_isActive && distanceToPlayer >= _settings.DeactivationDistance)
|
||||
DespawnWords();
|
||||
|
||||
if (_isActive)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
|
||||
{
|
||||
UpdateWordConvergence();
|
||||
UpdateContainerRotation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnWords()
|
||||
{
|
||||
if (_wordPrefab == null)
|
||||
{
|
||||
Debug.LogError($"[ObjectDescription] No word prefab assigned for {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
_isActive = true;
|
||||
_isMerged = false;
|
||||
_currentConvergenceRadius = _settings.SpawnRadius;
|
||||
|
||||
_wordsContainer = new GameObject($"{_descriptionText}_Container");
|
||||
_wordsContainer.transform.position = _descriptionAnchor.position;
|
||||
_wordsContainer.transform.rotation = _descriptionAnchor.rotation;
|
||||
|
||||
_wordsRotator = FloatingWordsSpawner.CreateWordsContainer(_descriptionText, _descriptionAnchor, _settings);
|
||||
_wordsRotator.transform.SetParent(_wordsContainer.transform, false);
|
||||
|
||||
ObjectDescriptionTriggerReference triggerRef = _wordsRotator.AddComponent<ObjectDescriptionTriggerReference>();
|
||||
triggerRef.Trigger = this;
|
||||
|
||||
List<DialogueChoiceData> dummyChoices = new List<DialogueChoiceData>();
|
||||
for (int i = 0; i < _settings.WordCount; i++)
|
||||
{
|
||||
dummyChoices.Add(new DialogueChoiceData
|
||||
{
|
||||
ChoiceText = _descriptionText,
|
||||
NextNode = null
|
||||
});
|
||||
}
|
||||
|
||||
_spawnedWords = FloatingWordsSpawner.SpawnByMode(
|
||||
dummyChoices,
|
||||
_wordPrefab,
|
||||
_wordsRotator.transform,
|
||||
_settings,
|
||||
out _originalSpawnPositions
|
||||
);
|
||||
|
||||
_basePositions = new Vector3[_spawnedWords.Count];
|
||||
for (int i = 0; i < _spawnedWords.Count; i++)
|
||||
_basePositions[i] = _spawnedWords[i].transform.localPosition;
|
||||
}
|
||||
|
||||
private void DespawnWords()
|
||||
{
|
||||
_isActive = false;
|
||||
_isMerged = false;
|
||||
|
||||
foreach (var word in _spawnedWords)
|
||||
{
|
||||
if (word != null)
|
||||
Destroy(word.gameObject);
|
||||
}
|
||||
|
||||
_spawnedWords.Clear();
|
||||
|
||||
if (_wordsContainer != null)
|
||||
Destroy(_wordsContainer);
|
||||
|
||||
// Debug.Log($"[ObjectDescription] Despawned words for '{descriptionText}'");
|
||||
}
|
||||
|
||||
private void UpdateWordConvergence()
|
||||
{
|
||||
if (_mainCamera == null || _spawnedWords.Count == 0) return;
|
||||
|
||||
Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f);
|
||||
Ray ray = _mainCamera.ScreenPointToRay(screenCenter);
|
||||
RaycastHit hit;
|
||||
|
||||
int layerMask = ~LayerMask.GetMask("Words", "UI");
|
||||
|
||||
bool isHoveringObject = false;
|
||||
|
||||
if (Physics.Raycast(ray, out hit, 100f, layerMask))
|
||||
{
|
||||
if (hit.collider.gameObject == gameObject || hit.collider.transform.IsChildOf(transform))
|
||||
isHoveringObject = true;
|
||||
}
|
||||
|
||||
float targetRadius = isHoveringObject ? _settings.MinConvergenceRadius : _settings.SpawnRadius;
|
||||
_currentConvergenceRadius = Mathf.Lerp(_currentConvergenceRadius, targetRadius, Time.deltaTime * _settings.ConvergenceSpeed);
|
||||
|
||||
if (isHoveringObject && !_isMerged && _currentConvergenceRadius <= _settings.MinConvergenceRadius * 5f)
|
||||
MergeWords();
|
||||
else if (!isHoveringObject && _isMerged)
|
||||
{
|
||||
if (!_settings.MergeOnce || !_hasBeenMergedOnce)
|
||||
UnmergeWords();
|
||||
}
|
||||
|
||||
if (_isMerged) return;
|
||||
|
||||
float convergenceFactor = Mathf.InverseLerp(0f, _settings.SpawnRadius, _currentConvergenceRadius);
|
||||
|
||||
for (int i = 0; i < _spawnedWords.Count; i++)
|
||||
{
|
||||
if (_spawnedWords[i] == null) continue;
|
||||
|
||||
Vector3 targetLocalPos = GetConvergenceTargetPosition(_originalSpawnPositions[i], _currentConvergenceRadius);
|
||||
|
||||
_basePositions[i] = Vector3.Lerp(
|
||||
_basePositions[i],
|
||||
targetLocalPos,
|
||||
Time.deltaTime * _settings.ConvergenceSpeed
|
||||
);
|
||||
|
||||
_spawnedWords[i].transform.localPosition = _basePositions[i] + _spawnedWords[i].GetShakeOffset(convergenceFactor);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetConvergenceTargetPosition(Vector3 originalPosition, float radius)
|
||||
{
|
||||
if (radius < 0.01f) return Vector3.zero;
|
||||
|
||||
switch (_settings.RotationAxis)
|
||||
{
|
||||
case RotationAxis.Forward:
|
||||
Vector2 xyNormalized = new Vector2(originalPosition.x, originalPosition.y - _settings.SpawnHeightOffset).normalized;
|
||||
return new Vector3(
|
||||
xyNormalized.x * radius,
|
||||
xyNormalized.y * radius + _settings.SpawnHeightOffset,
|
||||
originalPosition.z
|
||||
);
|
||||
|
||||
case RotationAxis.Up:
|
||||
Vector2 xzNormalized = new Vector2(originalPosition.x, originalPosition.z).normalized;
|
||||
return new Vector3(
|
||||
xzNormalized.x * radius,
|
||||
originalPosition.y,
|
||||
xzNormalized.y * radius
|
||||
);
|
||||
|
||||
case RotationAxis.Right:
|
||||
Vector2 yzNormalized = new Vector2(originalPosition.y - _settings.SpawnHeightOffset, originalPosition.z).normalized;
|
||||
return new Vector3(
|
||||
originalPosition.x,
|
||||
yzNormalized.x * radius + _settings.SpawnHeightOffset,
|
||||
yzNormalized.y * radius
|
||||
);
|
||||
|
||||
default:
|
||||
return originalPosition.normalized * radius;
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeWords()
|
||||
{
|
||||
if (_isMerged) return;
|
||||
|
||||
_isMerged = true;
|
||||
_hasBeenMergedOnce = true;
|
||||
|
||||
FloatingWordsSpawner.MergeWords(_spawnedWords, _settings, () =>
|
||||
{
|
||||
if (_settings.MergeOnce)
|
||||
{
|
||||
LeanTween.delayedCall(_settings.MergeOnceDisplayDuration, () =>
|
||||
{
|
||||
FadeOutAndComplete();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void FadeOutAndComplete()
|
||||
{
|
||||
if (_spawnedWords.Count > 0 && _spawnedWords[0] != null)
|
||||
{
|
||||
CanvasGroup canvasGroup = _spawnedWords[0].GetComponent<CanvasGroup>();
|
||||
if (canvasGroup != null)
|
||||
{
|
||||
LeanTween.value(_spawnedWords[0].gameObject, 1f, 0f, _settings.MergeDuration)
|
||||
.setOnUpdate((float val) => { canvasGroup.alpha = val; })
|
||||
.setEaseInQuad()
|
||||
.setOnComplete(() =>
|
||||
{
|
||||
_hasCompletedMergeOnce = true;
|
||||
DespawnWords();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UnmergeWords()
|
||||
{
|
||||
if (!_isMerged) return;
|
||||
|
||||
_isMerged = false;
|
||||
|
||||
FloatingWordsSpawner.UnmergeWords(_spawnedWords, _settings);
|
||||
}
|
||||
|
||||
private void UpdateContainerRotation()
|
||||
{
|
||||
FloatingWordsSpawner.UpdateContainerRotation(_wordsContainer, _wordsRotator, _playerTransform, _settings);
|
||||
}
|
||||
|
||||
public ObjectDescriptionSettings GetSettings()
|
||||
{
|
||||
return _settings;
|
||||
}
|
||||
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_settings == null) return;
|
||||
|
||||
Transform anchor = _descriptionAnchor != null ? _descriptionAnchor : transform;
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawWireSphere(transform.position, _settings.ActivationDistance);
|
||||
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireSphere(transform.position, _settings.DeactivationDistance);
|
||||
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawWireSphere(anchor.position, _settings.SpawnRadius);
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(anchor.position, _settings.MinConvergenceRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1fe462730ebdae488f89f5868a4c74c
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Interactions.FloatingWords
|
||||
{
|
||||
public class ObjectDescriptionTriggerReference : MonoBehaviour
|
||||
{
|
||||
public ObjectDescriptionTrigger Trigger;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ddb0acc42d86604c9dbf91872a216d9
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Gameplay.Items;
|
||||
using InputManager = Unheimlich.Managers.PlayerInputManager;
|
||||
|
||||
namespace Unheimlich.Interactions.Core
|
||||
{
|
||||
public class PlayerInspectionHandler : MonoBehaviour
|
||||
{
|
||||
public static PlayerInspectionHandler Instance { get; private set; }
|
||||
|
||||
[Header("Components")]
|
||||
[SerializeField] private Transform _inspectionHoldPoint;
|
||||
|
||||
[Header("State")]
|
||||
public bool IsInspecting { get; private set; } = false;
|
||||
private InspectableItem _currentInspectedItem;
|
||||
|
||||
private Coroutine _interactionListener;
|
||||
private bool _isUsingGamepad = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles inspection mode for an item. When inspecting, disables player movement
|
||||
/// and allows item rotation using look input until interaction key is pressed again.
|
||||
/// </summary>
|
||||
public void ToggleInspection(InspectableItem item)
|
||||
{
|
||||
if (!IsInspecting)
|
||||
{
|
||||
StartInspection(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_interactionListener != null)
|
||||
StopInspection();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartInspection(InspectableItem item)
|
||||
{
|
||||
IsInspecting = true;
|
||||
_currentInspectedItem = item;
|
||||
|
||||
InputManager.ToggleInput(false);
|
||||
|
||||
item.PrepareForInspection(_inspectionHoldPoint);
|
||||
|
||||
if (_interactionListener == null)
|
||||
_interactionListener = StartCoroutine(ListenForInteraction());
|
||||
|
||||
}
|
||||
|
||||
private void StopInspection()
|
||||
{
|
||||
if (_interactionListener != null)
|
||||
{
|
||||
StopCoroutine(_interactionListener);
|
||||
_interactionListener = null;
|
||||
}
|
||||
|
||||
StopInspectionInternal();
|
||||
}
|
||||
|
||||
private void StopInspectionInternal()
|
||||
{
|
||||
if (_currentInspectedItem != null)
|
||||
{
|
||||
_currentInspectedItem.Release();
|
||||
_currentInspectedItem = null;
|
||||
|
||||
StartCoroutine(DelayedToggleInput(true));
|
||||
}
|
||||
IsInspecting = false;
|
||||
}
|
||||
|
||||
private IEnumerator DelayedToggleInput(bool enable)
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
InputManager.ToggleInput(enable);
|
||||
}
|
||||
|
||||
|
||||
private IEnumerator ListenForInteraction()
|
||||
{
|
||||
yield return null;
|
||||
|
||||
while (IsInspecting)
|
||||
{
|
||||
if (InputManager.Instance.InteractAction.action.WasPressedThisFrame())
|
||||
{
|
||||
StopInspectionInternal();
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
_interactionListener = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (IsInspecting && _currentInspectedItem != null)
|
||||
{
|
||||
HandleObjectRotation();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleObjectRotation()
|
||||
{
|
||||
if (InputManager.Instance == null) return;
|
||||
|
||||
Vector2 lookInput = InputManager.Instance.LookInput;
|
||||
|
||||
if (lookInput != Vector2.zero)
|
||||
{
|
||||
if (Gamepad.current != null && Gamepad.current.rightStick.ReadValue().magnitude > 0.01f)
|
||||
_isUsingGamepad = true;
|
||||
else if (Mouse.current != null && Mouse.current.delta.ReadValue().magnitude > 0.01f)
|
||||
_isUsingGamepad = false;
|
||||
|
||||
float deltaMultiplier = _isUsingGamepad ? Time.deltaTime : 0.005f;
|
||||
|
||||
float rotX = lookInput.y * _currentInspectedItem.RotationSpeed * deltaMultiplier;
|
||||
float rotY = lookInput.x * _currentInspectedItem.RotationSpeed * deltaMultiplier;
|
||||
|
||||
_currentInspectedItem.transform.Rotate(transform.up, -rotY, Space.World);
|
||||
_currentInspectedItem.transform.Rotate(transform.right, rotX, Space.World);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 464960797e705e84a9eb87ecce4e9311
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4655ec1f5713c2240abc6ee0dabbf8df
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,214 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
using Unheimlich.Interactions.Dialogues;
|
||||
using Unheimlich.Interactions.FloatingWords;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
|
||||
namespace Unheimlich.Managers
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages gaze-based focus detection for dialogue choices using raycasts.
|
||||
/// Handles progressive focus timers, aim assist integration, visual feedback,
|
||||
/// and automatic choice selection when player maintains focus for a duration.
|
||||
/// </summary>
|
||||
public class DialogueFocusSystem
|
||||
{
|
||||
private readonly DialogueManager _manager;
|
||||
private readonly Camera _mainCamera;
|
||||
private readonly PlayerLook _playerLook;
|
||||
|
||||
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 _shakeIntensity;
|
||||
|
||||
private static readonly Vector3 ViewportCenter = new Vector3(0.5f, 0.5f, 0f);
|
||||
|
||||
public event Action<DialogueChoiceData> OnChoiceMade;
|
||||
|
||||
public DialogueFocusSystem(
|
||||
DialogueManager manager,
|
||||
Camera mainCamera,
|
||||
PlayerLook playerLook,
|
||||
LayerMask choiceLayer,
|
||||
float focusRange,
|
||||
float focusTime,
|
||||
float raycastInterval,
|
||||
float recoveryDuration,
|
||||
float focusRetentionTime,
|
||||
float shakeIntensity)
|
||||
{
|
||||
_manager = manager;
|
||||
_mainCamera = mainCamera;
|
||||
_playerLook = playerLook;
|
||||
_choiceLayer = choiceLayer;
|
||||
_focusRange = focusRange;
|
||||
_focusTime = focusTime;
|
||||
_raycastInterval = raycastInterval;
|
||||
_recoveryDuration = recoveryDuration;
|
||||
_focusRetentionTime = focusRetentionTime;
|
||||
_shakeIntensity = shakeIntensity;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mainCamera == null)
|
||||
return;
|
||||
|
||||
Ray ray = _mainCamera.ViewportPointToRay(ViewportCenter);
|
||||
|
||||
ChoiceFocusHandler hitHandler = null;
|
||||
bool raycastHitChoice = Physics.Raycast(ray, out RaycastHit hit, _focusRange, _choiceLayer);
|
||||
|
||||
if (raycastHitChoice)
|
||||
{
|
||||
hitHandler = hit.collider.GetComponent<ChoiceFocusHandler>();
|
||||
|
||||
if (_playerLook != null)
|
||||
_playerLook.ApplyAimAssist(hit.collider.bounds.center);
|
||||
}
|
||||
|
||||
if (hitHandler != _currentFocusedHandler)
|
||||
{
|
||||
_retentionTimer -= Time.deltaTime;
|
||||
|
||||
if (hitHandler != null && _retentionTimer <= 0f)
|
||||
{
|
||||
if (_currentFocusedHandler != null)
|
||||
{
|
||||
_isFocusLocked = true;
|
||||
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
|
||||
}
|
||||
|
||||
_currentFocusedHandler = hitHandler;
|
||||
_focusTimer = 0f;
|
||||
_retentionTimer = _focusRetentionTime;
|
||||
|
||||
if (_playerLook != null)
|
||||
_playerLook.LockAim(true);
|
||||
|
||||
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);
|
||||
|
||||
if (_playerLook != null)
|
||||
_playerLook.Shake(progress / _shakeIntensity);
|
||||
|
||||
if (_focusTimer >= _focusTime)
|
||||
{
|
||||
_isFocusLocked = true;
|
||||
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
|
||||
|
||||
if (_currentFocusedHandler.DialogueData != null)
|
||||
{
|
||||
OnChoiceMade?.Invoke(_currentFocusedHandler.DialogueData);
|
||||
DialogueNode nextNode = _currentFocusedHandler.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();
|
||||
|
||||
if (_playerLook != null)
|
||||
_playerLook.LockAim(false);
|
||||
}
|
||||
}
|
||||
else if (_currentFocusedHandler != null && _retentionTimer <= 0f)
|
||||
{
|
||||
_isFocusLocked = true;
|
||||
|
||||
_currentFocusedHandler.ResetFeedback(_recoveryDuration);
|
||||
|
||||
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 (_playerLook != null)
|
||||
_playerLook.LockAim(false);
|
||||
}
|
||||
}
|
||||
|
||||
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,240 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
using Unheimlich.UI;
|
||||
using Unheimlich.Interactions.Dialogues;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Unheimlich.Managers
|
||||
{
|
||||
public class DialogueManager : MonoBehaviour
|
||||
{
|
||||
public static DialogueManager Instance { get; private set; }
|
||||
|
||||
[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;
|
||||
[SerializeField] private float _shakeIntensity = 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 PlayerLook _playerLook;
|
||||
private DialogueAnimationSettings _currentAnimationSettings;
|
||||
private DialogueUI _activeDialogueUI;
|
||||
private Transform _currentChoiceAnchor;
|
||||
|
||||
private DialogueConversation _currentConversation;
|
||||
private DialogueNode _currentNode;
|
||||
private float _autoCloseTimer = 0f;
|
||||
private bool _isWaitingForAutoClose = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_playerLook = PlayerLook.Instance;
|
||||
_mainCamera = Camera.main;
|
||||
|
||||
_focusSystem = new DialogueFocusSystem(
|
||||
this,
|
||||
_mainCamera,
|
||||
_playerLook,
|
||||
_choiceLayer,
|
||||
_focusRange,
|
||||
_focusTime,
|
||||
_raycastInterval,
|
||||
_recoveryDuration,
|
||||
_focusRetentionTime,
|
||||
_shakeIntensity
|
||||
);
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
if (PlayerInputManager.Instance != null &&
|
||||
PlayerInputManager.Instance.InteractAction.action.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(_currentChoiceAnchor == null)
|
||||
{
|
||||
Debug.LogWarning("No choice anchor found!");
|
||||
return;
|
||||
}
|
||||
Transform anchor = _currentChoiceAnchor;
|
||||
|
||||
string speakerName = _currentConversation.SpeakerName;
|
||||
string dialogueText = _currentNode.DialogueText;
|
||||
|
||||
_activeDialogueUI.ShowDialogueBox(speakerName, dialogueText);
|
||||
|
||||
_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,102 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
|
||||
namespace Unheimlich.Managers
|
||||
{
|
||||
/// <summary>
|
||||
/// Dynamically adjusts Unity's shadow rendering distance based on proximity to active portals.
|
||||
/// When the player is near a portal with a loaded room, shadow distance is extended to ensure
|
||||
/// shadows from the remote room are visible through the portal. This improves visual quality
|
||||
/// while maintaining performance when far from portals.
|
||||
/// </summary>
|
||||
public class DynamicShadowDistance : MonoBehaviour
|
||||
{
|
||||
private const float PORTAL_CHECK_DISTANCE = 100f;
|
||||
|
||||
[Header("Shadow Distance Settings")]
|
||||
[SerializeField] private float _baseShadowDistance = 50f;
|
||||
[SerializeField] private float _extendedShadowDistance = 250f;
|
||||
[SerializeField] private float _transitionSpeed = 2f;
|
||||
|
||||
[Header("Detection")]
|
||||
[SerializeField] private float _checkInterval = 0.5f;
|
||||
|
||||
private float _targetShadowDistance;
|
||||
private float _currentShadowDistance;
|
||||
private float _nextCheckTime;
|
||||
private Transform _playerTransform;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_playerTransform = Camera.main.transform;
|
||||
_currentShadowDistance = _baseShadowDistance;
|
||||
_targetShadowDistance = _baseShadowDistance;
|
||||
QualitySettings.shadowDistance = _baseShadowDistance;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.time >= _nextCheckTime)
|
||||
{
|
||||
_nextCheckTime = Time.time + _checkInterval;
|
||||
UpdateTargetShadowDistance();
|
||||
}
|
||||
|
||||
if (!Mathf.Approximately(_currentShadowDistance, _targetShadowDistance))
|
||||
{
|
||||
_currentShadowDistance = Mathf.Lerp(_currentShadowDistance, _targetShadowDistance, Time.deltaTime * _transitionSpeed);
|
||||
QualitySettings.shadowDistance = _currentShadowDistance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any active portal with a loaded room is within range.
|
||||
/// If found, extends shadow distance to cover the remote room.
|
||||
/// </summary>
|
||||
private void UpdateTargetShadowDistance()
|
||||
{
|
||||
bool needsExtendedDistance = false;
|
||||
|
||||
foreach (var portal in Portal.AllPortals)
|
||||
{
|
||||
if (portal == null || !portal.isActiveAndEnabled)
|
||||
continue;
|
||||
|
||||
if (portal.LoadedRoomInstance != null)
|
||||
{
|
||||
float distanceToPortal = Vector3.Distance(_playerTransform.position, portal.transform.position);
|
||||
|
||||
if (distanceToPortal < PORTAL_CHECK_DISTANCE)
|
||||
{
|
||||
needsExtendedDistance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_targetShadowDistance = needsExtendedDistance ? _extendedShadowDistance : _baseShadowDistance;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (_baseShadowDistance < 0)
|
||||
_baseShadowDistance = 0;
|
||||
if (_extendedShadowDistance < _baseShadowDistance)
|
||||
_extendedShadowDistance = _baseShadowDistance;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_playerTransform == null)
|
||||
return;
|
||||
|
||||
Gizmos.color = new Color(1f, 1f, 0f, 0.2f);
|
||||
Gizmos.DrawWireSphere(_playerTransform.position, QualitySettings.shadowDistance);
|
||||
|
||||
UnityEditor.Handles.Label(_playerTransform.position + Vector3.up * 2,
|
||||
$"Shadow Distance: {QualitySettings.shadowDistance:F0}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8f216e6440b9d445ac547aa24daf2b7
|
||||
@@ -0,0 +1,94 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Unheimlich.Managers
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralizes Input System handling for the player.
|
||||
/// Manages input state, action references, and global input enable/disable functionality.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(PlayerInput))]
|
||||
public class PlayerInputManager : MonoBehaviour
|
||||
{
|
||||
public static PlayerInputManager Instance { get; private set; }
|
||||
|
||||
[Header("Input Values (Read only)")]
|
||||
public Vector2 MoveInput;
|
||||
public Vector2 LookInput;
|
||||
public InputActionReference JumpAction;
|
||||
public InputActionReference InteractAction;
|
||||
|
||||
public static bool IsInputEnabled { get; private set; } = true;
|
||||
public static bool IsLookingEnabled { get; private set; } = true;
|
||||
|
||||
private InputActionMap _playerActionMap;
|
||||
private InputAction _lookAction;
|
||||
private InputAction _interactAction;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
IsInputEnabled = true;
|
||||
|
||||
PlayerInput playerInput = GetComponent<PlayerInput>();
|
||||
playerInput.actions.Enable();
|
||||
|
||||
_playerActionMap = playerInput.actions.FindActionMap("Player");
|
||||
_lookAction = _playerActionMap.FindAction("Look");
|
||||
_interactAction = _playerActionMap.FindAction("Interact");
|
||||
|
||||
JumpAction.action.Enable();
|
||||
InteractAction.action.Enable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Globally toggles player input on/off. When disabled, only look and interact actions remain active.
|
||||
/// Used during dialogues, inspections, or UI interactions.
|
||||
/// </summary>
|
||||
public static void ToggleInput(bool enable)
|
||||
{
|
||||
if (Instance == null) return;
|
||||
|
||||
IsInputEnabled = enable;
|
||||
|
||||
if (enable)
|
||||
{
|
||||
IsLookingEnabled = true;
|
||||
Instance._playerActionMap.Enable();
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Instance._playerActionMap.Disable();
|
||||
Instance._lookAction.Enable();
|
||||
Instance._interactAction.Enable();
|
||||
IsLookingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnMove(InputAction.CallbackContext context)
|
||||
{
|
||||
MoveInput = context.ReadValue<Vector2>();
|
||||
}
|
||||
|
||||
public void Look(InputAction.CallbackContext context)
|
||||
{
|
||||
LookInput = context.ReadValue<Vector2>();
|
||||
}
|
||||
|
||||
public void OnJump(InputAction.CallbackContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnInteract(InputAction.CallbackContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 860eff61af1c75243b697035c7b7fc83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences:
|
||||
- JumpAction: {fileID: -2099379676528639254, guid: 052faaac586de48259a63d0c4782560b, type: 3}
|
||||
- InteractAction: {fileID: 1781555164194001046, guid: 052faaac586de48259a63d0c4782560b, type: 3}
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Gameplay.Portals;
|
||||
|
||||
namespace Unheimlich.Managers
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages dynamic loading and unloading of rooms connected by portals.
|
||||
/// Monitors player distance to portals and loads/unloads room instances accordingly.
|
||||
/// Supports room pooling (keeping inactive rooms in memory) and distance-based activation.
|
||||
/// </summary>
|
||||
public class PortalRoomManager : MonoBehaviour
|
||||
{
|
||||
public static PortalRoomManager Instance { get; private set; }
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float _loadDistance = 15f;
|
||||
[Tooltip("Distance at which a loaded room gets deactivated (must be > load distance to avoid flip-flop)")]
|
||||
[SerializeField] private float _deactivateDistance = 50f;
|
||||
[Tooltip("Distance at which a deactivated room gets reactivated (should be < deactivate distance)")]
|
||||
[SerializeField] private float _reactivateDistance = 30f;
|
||||
[SerializeField] private bool _keepRoomsInMemory = true;
|
||||
|
||||
[Header("Distance Calculation")]
|
||||
[Tooltip("If true, calculates distance from player to actual room position (portal + loadRoomDistance)")]
|
||||
[SerializeField] private bool _useRealRoomDistance = true;
|
||||
|
||||
private Transform _player;
|
||||
private Dictionary<string, LoadedRoom> _loadedRooms = new Dictionary<string, LoadedRoom>();
|
||||
private List<Portal> _registeredPortals = new List<Portal>();
|
||||
private GameObject _currentPlayerRoom = null;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a room instance that has been loaded through a portal.
|
||||
/// Caches room bounds for efficient distance calculations.
|
||||
/// </summary>
|
||||
private class LoadedRoom
|
||||
{
|
||||
public GameObject RoomInstance;
|
||||
public string RoomId;
|
||||
public Vector3 LoadPosition;
|
||||
public Portal SourcePortal;
|
||||
public Bounds CachedBounds;
|
||||
public bool BoundsCalculated = false;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_player = GameObject.FindGameObjectWithTag("Player")?.transform;
|
||||
if (_player == null)
|
||||
Debug.LogWarning("Player not found! Make sure your player has the 'Player' tag.");
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_player == null)
|
||||
return;
|
||||
|
||||
CheckPortalsForLoading();
|
||||
CheckRoomsForUnloading();
|
||||
}
|
||||
|
||||
public void RegisterPortal(Portal portal)
|
||||
{
|
||||
if (!_registeredPortals.Contains(portal))
|
||||
_registeredPortals.Add(portal);
|
||||
}
|
||||
|
||||
public void UnregisterPortal(Portal portal)
|
||||
{
|
||||
if (_registeredPortals.Contains(portal))
|
||||
_registeredPortals.Remove(portal);
|
||||
}
|
||||
|
||||
public GameObject GetLoadedRoom(string roomId)
|
||||
{
|
||||
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
|
||||
return loadedRoom.RoomInstance;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks all registered portals and loads rooms for portals within range.
|
||||
/// Optionally accounts for the portal's configured room spawn distance.
|
||||
/// </summary>
|
||||
private void CheckPortalsForLoading()
|
||||
{
|
||||
foreach (Portal portal in _registeredPortals)
|
||||
{
|
||||
float distanceToPortal = Vector3.Distance(_player.position, portal.transform.position);
|
||||
float effectiveLoadDistance = _loadDistance;
|
||||
|
||||
if (_useRealRoomDistance)
|
||||
effectiveLoadDistance += portal.GetRoomLoadDistance();
|
||||
|
||||
if (distanceToPortal <= effectiveLoadDistance)
|
||||
LoadRoom(portal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks all loaded rooms and determines if they should be deactivated, reactivated, or unloaded
|
||||
/// based on distance thresholds. Prevents unloading the room the player is currently inside.
|
||||
/// </summary>
|
||||
private void CheckRoomsForUnloading()
|
||||
{
|
||||
List<string> roomsToUnload = new List<string>();
|
||||
|
||||
UpdateCurrentPlayerRoom();
|
||||
|
||||
foreach (var kvp in _loadedRooms)
|
||||
{
|
||||
LoadedRoom loadedRoom = kvp.Value;
|
||||
if (loadedRoom.RoomInstance == null)
|
||||
{
|
||||
roomsToUnload.Add(kvp.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadedRoom.RoomInstance == _currentPlayerRoom)
|
||||
continue;
|
||||
|
||||
float distanceToPortal = GetDistanceToRoom(loadedRoom);
|
||||
float effectiveDeactivateDistance = _deactivateDistance;
|
||||
float effectiveReactivateDistance = _reactivateDistance;
|
||||
|
||||
if (_useRealRoomDistance)
|
||||
{
|
||||
float roomOffset = loadedRoom.SourcePortal.GetRoomLoadDistance();
|
||||
effectiveDeactivateDistance += roomOffset;
|
||||
effectiveReactivateDistance += roomOffset;
|
||||
}
|
||||
|
||||
if (distanceToPortal > effectiveDeactivateDistance)
|
||||
{
|
||||
if (loadedRoom.RoomInstance.activeSelf)
|
||||
roomsToUnload.Add(kvp.Key);
|
||||
}
|
||||
else if (distanceToPortal <= effectiveReactivateDistance)
|
||||
{
|
||||
if (!loadedRoom.RoomInstance.activeSelf)
|
||||
loadedRoom.RoomInstance.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string roomId in roomsToUnload)
|
||||
{
|
||||
UnloadRoom(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the distance from the player to a loaded room.
|
||||
/// Lazily calculates and caches room bounds on first call for performance.
|
||||
/// </summary>
|
||||
private float GetDistanceToRoom(LoadedRoom loadedRoom)
|
||||
{
|
||||
if (!loadedRoom.BoundsCalculated)
|
||||
{
|
||||
loadedRoom.CachedBounds = CalculateRoomBounds(loadedRoom.RoomInstance);
|
||||
loadedRoom.BoundsCalculated = true;
|
||||
}
|
||||
|
||||
return Vector3.Distance(_player.position, loadedRoom.CachedBounds.center);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the combined bounding box of all renderers in a room.
|
||||
/// Used to determine room center for distance calculations.
|
||||
/// </summary>
|
||||
private Bounds CalculateRoomBounds(GameObject room)
|
||||
{
|
||||
Renderer[] renderers = room.GetComponentsInChildren<Renderer>();
|
||||
if (renderers.Length == 0)
|
||||
return new Bounds(room.transform.position, Vector3.one);
|
||||
|
||||
Bounds bounds = renderers[0].bounds;
|
||||
for (int i = 1; i < renderers.Length; i++)
|
||||
bounds.Encapsulate(renderers[i].bounds);
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which room (if any) currently contains the player.
|
||||
/// Uses collider bounds checks to detect room occupancy.
|
||||
/// </summary>
|
||||
private void UpdateCurrentPlayerRoom()
|
||||
{
|
||||
_currentPlayerRoom = null;
|
||||
|
||||
foreach (var kvp in _loadedRooms)
|
||||
{
|
||||
LoadedRoom loadedRoom = kvp.Value;
|
||||
if (loadedRoom.RoomInstance == null)
|
||||
continue;
|
||||
|
||||
Collider[] roomColliders = loadedRoom.RoomInstance.GetComponentsInChildren<Collider>();
|
||||
foreach (Collider col in roomColliders)
|
||||
{
|
||||
if (col.bounds.Contains(_player.position))
|
||||
{
|
||||
_currentPlayerRoom = loadedRoom.RoomInstance;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a room from a portal's prefab at the configured spawn point.
|
||||
/// If the room is already loaded, reactivates it instead of creating a new instance.
|
||||
/// Notifies the portal when its room is successfully loaded.
|
||||
/// </summary>
|
||||
private void LoadRoom(Portal portal)
|
||||
{
|
||||
GameObject roomPrefab = portal.GetRoomPrefab();
|
||||
if (roomPrefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[LOAD] Portal {portal.name} has no prefab!");
|
||||
return;
|
||||
}
|
||||
|
||||
string roomId = portal.GetRoomId();
|
||||
|
||||
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom existingRoom))
|
||||
{
|
||||
if (existingRoom.RoomInstance != null)
|
||||
existingRoom.RoomInstance.SetActive(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 spawnPosition = portal.GetRoomSpawnPosition();
|
||||
Quaternion spawnRotation = portal.GetRoomSpawnRotation();
|
||||
|
||||
|
||||
GameObject roomInstance = Instantiate(roomPrefab, spawnPosition, spawnRotation);
|
||||
roomInstance.name = $"{roomPrefab.name}_Loaded";
|
||||
|
||||
foreach (var renderer in roomInstance.GetComponentsInChildren<MeshRenderer>())
|
||||
renderer.gameObject.SendMessage("OnBecameVisible", SendMessageOptions.DontRequireReceiver);
|
||||
|
||||
LoadedRoom loadedRoom = new LoadedRoom
|
||||
{
|
||||
RoomInstance = roomInstance,
|
||||
RoomId = roomId,
|
||||
LoadPosition = portal.transform.position,
|
||||
SourcePortal = portal
|
||||
};
|
||||
|
||||
_loadedRooms.Add(roomId, loadedRoom);
|
||||
|
||||
portal.OnRoomLoaded(roomInstance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates or destroys a loaded room based on the _keepRoomsInMemory setting.
|
||||
/// If keeping in memory, the room is deactivated but not destroyed (faster to reactivate).
|
||||
/// If not keeping in memory, the room is fully destroyed and removed from tracking.
|
||||
/// Notifies the source portal when its room is unloaded.
|
||||
/// </summary>
|
||||
private void UnloadRoom(string roomId)
|
||||
{
|
||||
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
|
||||
{
|
||||
if (loadedRoom.RoomInstance != null)
|
||||
{
|
||||
if (loadedRoom.SourcePortal != null)
|
||||
loadedRoom.SourcePortal.OnRoomUnloaded();
|
||||
|
||||
if (_keepRoomsInMemory)
|
||||
loadedRoom.RoomInstance.SetActive(false);
|
||||
else
|
||||
{
|
||||
Destroy(loadedRoom.RoomInstance);
|
||||
_loadedRooms.Remove(roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25abbf68f203db94381b5c22eb1573bc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f422bd47f41542c4aa564d779e74f544
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.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,214 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
|
||||
namespace Unheimlich.UI
|
||||
{
|
||||
[RequireComponent(typeof(Camera))]
|
||||
public class DialogueBlurEffectURP : MonoBehaviour
|
||||
{
|
||||
public static DialogueBlurEffectURP Instance { get; private set; }
|
||||
|
||||
[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;
|
||||
|
||||
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;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(this);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
_baseCam = GetComponent<Camera>();
|
||||
|
||||
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()
|
||||
{
|
||||
if (_autoCreateDialogueCamera)
|
||||
SetupDialogueOverlayCamera();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 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 (_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,326 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using Unheimlich.Data.Dialogues;
|
||||
using Unheimlich.Gameplay.Player;
|
||||
using Unheimlich.Interactions.FloatingWords;
|
||||
using Unheimlich.Interactions.Dialogues;
|
||||
|
||||
namespace Unheimlich.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")]
|
||||
[SerializeField] private GameObject _dialoguePanel;
|
||||
public GameObject ChoiceContainer;
|
||||
|
||||
[Header("Text Fields")]
|
||||
[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 List<ChoiceFocusHandler> _activeChoices = new List<ChoiceFocusHandler>();
|
||||
private float _sortTimer = 0f;
|
||||
private const float SORT_INTERVAL = 0.1f;
|
||||
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Player.Instance == null || !ChoiceContainer.activeInHierarchy)
|
||||
return;
|
||||
|
||||
if (_shouldFollowPlayerPosition)
|
||||
ChoiceContainer.transform.position = Player.Instance.transform.position;
|
||||
|
||||
if (_shouldFollowPlayerRotation)
|
||||
{
|
||||
float playerYRotation = Player.Instance.transform.eulerAngles.y;
|
||||
ChoiceContainer.transform.rotation = Quaternion.Euler(0f, playerYRotation, 0f);
|
||||
}
|
||||
|
||||
if (_currentAnimationSettings != null && _currentAnimationSettings.EnableDistanceScaling && !_shouldFollowPlayerPosition)
|
||||
ApplyDistanceScaling();
|
||||
|
||||
SortChoicesByDistance();
|
||||
}
|
||||
|
||||
public void ShowDialogueBox(string speaker, string content)
|
||||
{
|
||||
_speakerText.text = speaker;
|
||||
_contentText.text = content;
|
||||
_dialoguePanel.SetActive(true);
|
||||
|
||||
if (_dialogueCanvasGroup != null)
|
||||
{
|
||||
LeanTween.cancel(_dialoguePanel);
|
||||
_dialogueCanvasGroup.alpha = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
public void HideDialogueBox()
|
||||
{
|
||||
_dialoguePanel.SetActive(false);
|
||||
ChoiceContainer.SetActive(false);
|
||||
HideChoices();
|
||||
}
|
||||
|
||||
public void FadeOutAndHide(System.Action onComplete = null)
|
||||
{
|
||||
if (_dialogueCanvasGroup != null)
|
||||
{
|
||||
LeanTween.cancel(_dialoguePanel);
|
||||
LeanTween.alphaCanvas(_dialogueCanvasGroup, 0f, _fadeOutDuration)
|
||||
.setOnComplete(() =>
|
||||
{
|
||||
HideDialogueBox();
|
||||
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 && Player.Instance != null)
|
||||
{
|
||||
Vector3 playerWorldPos = Player.Instance.transform.position;
|
||||
ChoiceContainer.transform.position = playerWorldPos;
|
||||
}
|
||||
else
|
||||
ChoiceContainer.transform.localPosition = Vector3.zero;
|
||||
|
||||
if (followRotation && Player.Instance != null)
|
||||
{
|
||||
float playerYRotation = Player.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();
|
||||
|
||||
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 offset = index * 0.2f;
|
||||
|
||||
Vector3 startPos = choice.transform.localPosition;
|
||||
|
||||
LeanTween.value(choice, 0f, Mathf.PI * 2f, duration)
|
||||
.setDelay(offset)
|
||||
.setOnUpdate((float val) => {
|
||||
if (choice != null)
|
||||
{
|
||||
Vector3 newPos = startPos;
|
||||
newPos.y += Mathf.Sin(val) * amplitude;
|
||||
choice.transform.localPosition = newPos;
|
||||
}
|
||||
})
|
||||
.setLoopClamp()
|
||||
.setEase(LeanTweenType.linear);
|
||||
}
|
||||
|
||||
public void RotationAnimation(float degreesPerSecond = 60f)
|
||||
{
|
||||
if (degreesPerSecond <= 0f)
|
||||
return;
|
||||
|
||||
float duration = 360f / degreesPerSecond;
|
||||
|
||||
LeanTween.cancel(ChoiceContainer);
|
||||
LeanTween.rotateAroundLocal(ChoiceContainer, _currentRotationAxis, 360f, duration)
|
||||
.setLoopClamp()
|
||||
.setEase(LeanTweenType.linear);
|
||||
}
|
||||
|
||||
/// <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 || Player.Instance == null)
|
||||
return;
|
||||
|
||||
float distance = Vector3.Distance(_currentAnchor.position, Player.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 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();
|
||||
LeanTween.cancel(ChoiceContainer);
|
||||
foreach (Transform child in ChoiceContainer.transform)
|
||||
{
|
||||
LeanTween.cancel(child.gameObject);
|
||||
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 || Player.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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user