diff --git a/Assets/Scripts/Audio/SoundEffect.cs b/Assets/Scripts/Audio/SoundEffect.cs index 30829f3..51016f9 100644 --- a/Assets/Scripts/Audio/SoundEffect.cs +++ b/Assets/Scripts/Audio/SoundEffect.cs @@ -15,8 +15,7 @@ namespace Prototype public AudioMixerGroup audioMixerGroup; public float volumeMultiplierFactor = 1; [SerializeField] bool loop; - [SerializeField] Vector2 fade = new(); - + [SerializeField] bool spatialize; [Header("Additional Settings")] [SerializeField, Range(0, 1)] float defaultVolume = .8f; @@ -36,8 +35,8 @@ namespace Prototype volumeMultiplier = Random.Range(volumeMultiplier.x, volumeMultiplier.y) * volumeMultiplierFactor, pitch = Random.Range(pitch.x, pitch.y), loop = loop, - distanceRange = distanceRange ? distanceRange.range : null, - fade = fade + spatialize = spatialize, + distanceRange = distanceRange ? distanceRange.range : null }; #if UNITY_EDITOR diff --git a/Assets/Scripts/Audio/SoundEffectInstance.cs b/Assets/Scripts/Audio/SoundEffectInstance.cs index 2d0e07a..c276ea2 100644 --- a/Assets/Scripts/Audio/SoundEffectInstance.cs +++ b/Assets/Scripts/Audio/SoundEffectInstance.cs @@ -26,6 +26,7 @@ namespace Prototype var range = options.GetDistanceRange(); audioSource.spatialBlend = range.x == 0 && range.y == 0 ? 0 : 1; + audioSource.spatialize = options.GetSpatialize() && audioSource.spatialBlend > 0; audioSource.rolloffMode = AudioRolloffMode.Linear; audioSource.minDistance = range.x; audioSource.maxDistance = range.y; diff --git a/Assets/Scripts/Audio/SoundEffectOptions.cs b/Assets/Scripts/Audio/SoundEffectOptions.cs index 06200d2..f0408d4 100644 --- a/Assets/Scripts/Audio/SoundEffectOptions.cs +++ b/Assets/Scripts/Audio/SoundEffectOptions.cs @@ -20,13 +20,13 @@ namespace Prototype public Vector2? distanceRange; public Vector2 GetDistanceRange() => distanceRange ?? new(30, 200); - public Vector2? fade; - public Vector2 GetFade() => fade ?? new(); - public bool? destroyOnLoad; public bool ShouldDestroyOnLoad() => destroyOnLoad ?? true; public float? delay; public float GetDelay() => delay ?? 0; + + public bool? spatialize; + public bool GetSpatialize() => spatialize ?? false; } } diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index 8c00189..dfe992a 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -169,8 +169,8 @@ public class MatchManager : MonoBehaviour [Header("Radar pings")] [Tooltip("Seconds between radar sweeps during Seek. Every interval each still-un-found hider is registered " + - "as a blip on every HUNTER's radar (hiders and spectators never receive them). Fires only in the " + - "Seek phase. 0 disables radar pings.")] + "as a blip on every HUNTER's radar (hiders and spectators get no blips, only the taunt sound). " + + "Fires only in the Seek phase. 0 disables radar pings.")] [Min(0f)] [SerializeField] private float radarPingInterval = 30f; @@ -179,6 +179,12 @@ public class MatchManager : MonoBehaviour [Min(0f)] [SerializeField] private float radarPingLifetime = 3f; + [Tooltip("Taunt sound played at each pinged hider's world position on EVERY client (hunters, hiders and " + + "spectators alike) whenever a radar ping fires. Multiple taunts in one sweep are staggered by " + + "audio-listener distance so the same sample never starts twice at once (phase cancellation). " + + "Empty = no sound.")] + [SerializeField] private Prototype.SoundEffect radarPingSound; + /// Range of the hunter's shoot raycast (read by ). public float ShootRange => shootRange; /// Hunter shoot cooldown in seconds (read by ). @@ -267,8 +273,9 @@ public class MatchManager : MonoBehaviour // app restarts). Lets the server map the ephemeral NGO clientId to a stable identity - the reconnect // foundation. See PersistentPlayerId. private const string MsgIdentity = "Match_Identity"; - // Server -> HUNTER clients only, on each Seek radar sweep: the still-un-found hiders' NetworkObjectIds plus - // the blip lifetime. Each hunter resolves the ids locally and drops a Radar blip pointing at the hider. + // Server -> all clients, on each Seek radar sweep: the still-un-found hiders' NetworkObjectIds plus the blip + // lifetime and a spawnBlips flag (true only for hunters). Each client resolves the ids locally; hunters drop + // a Radar blip per hider, and everyone plays the ping taunt at each hider's position. private const string MsgRadarPing = "Match_RadarPing"; // Pre-match LOADING barrier. Server -> all clients, once the match avatars are respawned: the target avatar // NetworkObjectIds + a generation tag; each client waits until it has spawned & paint-applied all of them. @@ -303,6 +310,8 @@ public class MatchManager : MonoBehaviour private readonly List _phaseRecipients = new List(); // reused scratch for the phase broadcast private readonly List _radarPingIds = new List(); // reused scratch: hider NetworkObjectIds for a sweep private readonly List _radarRecipients = new List(); // reused scratch: hunter client recipients for a sweep + private readonly List _radarSoundRecipients = new List(); // reused scratch: non-hunter recipients (taunt only, no blips) + private readonly List _radarSoundPositions = new List(); // reused scratch: pinged hider positions for the taunt stagger // Server-only readiness barrier. _barrierTargets = the objects every client must have spawned & paint-applied // this round (the freshly respawned avatars pre-match, or the conversion statues at Resolution); _expectedReady @@ -1117,8 +1126,8 @@ public class MatchManager : MonoBehaviour } // Server-only. One radar sweep during Seek: registers every still-un-found hider as a blip on each HUNTER's - // radar. Sends the hiders' NetworkObjectIds + blip lifetime to the hunter clients; a host-hunter applies - // locally (no loopback message, mirroring SetPhaseServer). Hiders and spectators never receive a ping. + // radar and plays the ping taunt at each hider on EVERY client. A host applies locally (no loopback message, + // mirroring SetPhaseServer). private void BroadcastRadarPings() { if (!_net || !_net.IsServer || _pendingHiders.Count == 0) return; @@ -1130,33 +1139,45 @@ public class MatchManager : MonoBehaviour EmitRadarPings(); } - // Server-only. Sends whatever hider NetworkObjectIds are staged in _radarPingIds to every HUNTER as radar - // blips (a host-hunter applies locally, no loopback - mirroring SetPhaseServer). Shared by the interval - // sweep and the manual single-hider ping so both drop the identical blip (same lifetime, same path). - // No-op if nothing is staged or there is no hunter to receive it. + // Server-only. Sends whatever hider NetworkObjectIds are staged in _radarPingIds to every client (the host + // applies locally, no loopback - mirroring SetPhaseServer). Hunters get radar blips + the taunt sound; + // everyone else (hiders, spectators) only hears the taunt at the pinged positions - the spawnBlips flag in + // the payload keeps the radar hunter-only. Shared by the interval sweep and the manual single-hider ping. private void EmitRadarPings() { if (_radarPingIds.Count == 0) return; - // Host-as-hunter: spawn on its own radar directly (it is excluded from the recipient list below). + // Host: apply locally (excluded from the recipient lists below) - blips only as a hunter, taunt always. if (IsHunter(_net.LocalClientId)) SpawnRadarBlipsLocal(); - - _radarRecipients.Clear(); - foreach (ulong clientId in _net.ConnectedClientsIds) - if (clientId != _net.LocalClientId && IsHunter(clientId)) _radarRecipients.Add(clientId); - if (_radarRecipients.Count == 0) return; + StagePingTauntsFromIds(); var cmm = _net.CustomMessagingManager; if (cmm == null) return; - int size = sizeof(float) + sizeof(int) + _radarPingIds.Count * sizeof(ulong); - using (var writer = new FastBufferWriter(size, Allocator.Temp)) + + _radarRecipients.Clear(); + _radarSoundRecipients.Clear(); + foreach (ulong clientId in _net.ConnectedClientsIds) { - writer.WriteValueSafe(radarPingLifetime); - writer.WriteValueSafe(_radarPingIds.Count); - for (int i = 0; i < _radarPingIds.Count; i++) - writer.WriteValueSafe(_radarPingIds[i]); - cmm.SendNamedMessage(MsgRadarPing, _radarRecipients, writer); + if (clientId == _net.LocalClientId) continue; + (IsHunter(clientId) ? _radarRecipients : _radarSoundRecipients).Add(clientId); } + SendRadarPing(cmm, _radarRecipients, spawnBlips: true); + SendRadarPing(cmm, _radarSoundRecipients, spawnBlips: false); + } + + // Writes the staged _radarPingIds as one MsgRadarPing to the given recipients. spawnBlips tells the + // receiver whether to also drop radar blips (hunters) or just play the taunt (hiders/spectators). + private void SendRadarPing(CustomMessagingManager cmm, List recipients, bool spawnBlips) + { + if (recipients.Count == 0) return; + int size = sizeof(float) + sizeof(byte) + sizeof(int) + _radarPingIds.Count * sizeof(ulong); + using var writer = new FastBufferWriter(size, Allocator.Temp); + writer.WriteValueSafe(radarPingLifetime); + writer.WriteValueSafe(spawnBlips); + writer.WriteValueSafe(_radarPingIds.Count); + for (int i = 0; i < _radarPingIds.Count; i++) + writer.WriteValueSafe(_radarPingIds[i]); + cmm.SendNamedMessage(MsgRadarPing, recipients, writer); } /// Client-side: the local hider manually requests a radar ping on itself (the E key), making it @@ -1204,20 +1225,63 @@ public class MatchManager : MonoBehaviour radar.Spawn(no.transform, radarPingLifetime); } - // Hunter-client handler: a radar sweep arrived. Resolve each hider NetworkObjectId to its live transform and - // drop a blip on the local radar. Only hunter clients are sent this message (filtered server-side). + // Client handler: a radar sweep arrived. Resolve each hider NetworkObjectId to its live transform, drop a + // blip on the local radar (hunters only - spawnBlips is filtered server-side) and play the ping taunt at + // each hider's position (every client). private void OnRadarPingMessage(ulong senderClientId, FastBufferReader reader) { reader.ReadValueSafe(out float lifetime); + reader.ReadValueSafe(out bool spawnBlips); reader.ReadValueSafe(out int count); var radar = Radar.Instance; var spawnMgr = _net ? _net.SpawnManager : null; + _radarSoundPositions.Clear(); for (int i = 0; i < count; i++) { reader.ReadValueSafe(out ulong netObjId); - if (radar && spawnMgr != null && - spawnMgr.SpawnedObjects.TryGetValue(netObjId, out var no) && no) - radar.Spawn(no.transform, lifetime); + if (spawnMgr == null || !spawnMgr.SpawnedObjects.TryGetValue(netObjId, out var no) || !no) continue; + if (spawnBlips && radar) radar.Spawn(no.transform, lifetime); + _radarSoundPositions.Add(no.transform.position); + } + PlayStaggeredPingTaunts(); + } + + // Resolves the staged _radarPingIds to world positions and plays the taunts (host/server local path - + // mirrors what OnRadarPingMessage does for remote clients). + private void StagePingTauntsFromIds() + { + if (!radarPingSound) return; + var spawnMgr = _net ? _net.SpawnManager : null; + if (spawnMgr == null) return; + _radarSoundPositions.Clear(); + for (int i = 0; i < _radarPingIds.Count; i++) + if (spawnMgr.SpawnedObjects.TryGetValue(_radarPingIds[i], out var no) && no) + _radarSoundPositions.Add(no.transform.position); + PlayStaggeredPingTaunts(); + } + + // Plays the ping taunt at every staged position - staggered, never simultaneous. The same sample starting + // at several world positions in the same audio frame phase-cancels, so each instance is delayed by its + // distance to the local audio listener at the speed of sound (natural propagation: far players are heard + // later), with a minimum gap between consecutive starts as the tie-break for equidistant players. + private void PlayStaggeredPingTaunts() + { + const float speedOfSound = 343f; // m/s + const float minStartGap = 0.035f; // s between consecutive starts + + if (!radarPingSound || _radarSoundPositions.Count == 0) return; + + var listener = FindFirstObjectByType(); + Vector3 ear = listener ? listener.transform.position + : Camera.main ? Camera.main.transform.position : Vector3.zero; + + _radarSoundPositions.Sort((a, b) => (a - ear).sqrMagnitude.CompareTo((b - ear).sqrMagnitude)); + float previousDelay = -minStartGap; + foreach (Vector3 pos in _radarSoundPositions) + { + float delay = Mathf.Max(Vector3.Distance(pos, ear) / speedOfSound, previousDelay + minStartGap); + radarPingSound.Play(pos, delay); + previousDelay = delay; } }