using UnityEngine; using System.Collections.Generic; using Unheimlich.Gameplay.Portals; namespace Unheimlich.Audio { /// /// 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. /// 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 _remoteSources = new List(); private Portal _portal; private Dictionary _proxyMapping = new Dictionary(); private Dictionary _filterMapping = new Dictionary(); private GameObject _lastLoadedRoomInstance; private bool _needsRefresh; private void Awake() { _portal = GetComponent(); } 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; } /// /// Main update loop: refreshes audio sources when the room changes, /// then updates all proxy positions and volumes each frame. /// 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(); } /// /// Scans the loaded room for all AudioSource components and caches them. /// Called whenever a new room is loaded through the portal. /// private void RefreshRemoteSources() { GameObject linkedRoom = _portal.LoadedRoomInstance; if (linkedRoom == null) { _remoteSources.Clear(); return; } AudioSource[] sources = linkedRoom.GetComponentsInChildren(); _remoteSources.Clear(); _remoteSources.AddRange(sources); } /// /// 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. /// 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 toRemove = new List(); 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(); 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(); 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(); 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(); 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); } } }