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);
}
}
}