diff --git a/Content.Client/_Scp/Audio/AudioEchoSystem.cs b/Content.Client/_Scp/Audio/AudioEchoSystem.cs new file mode 100644 index 00000000000..dac4dd88885 --- /dev/null +++ b/Content.Client/_Scp/Audio/AudioEchoSystem.cs @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: 2025 LaCumbiaDelCoronavirus +// SPDX-FileCopyrightText: 2025 ark1368 +// +// SPDX-License-Identifier: MPL-2.0 + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using Content.Client.Light.EntitySystems; +using Content.Shared._Scp.ScpCCVars; +using Content.Shared.Light.Components; +using Content.Shared.Physics; +using Robust.Client.GameObjects; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Components; +using Robust.Shared.Configuration; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Physics; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using DependencyAttribute = Robust.Shared.IoC.DependencyAttribute; + +namespace Content.Client._Scp.Audio; + +/// +/// Handles making sounds 'echo' in large, open spaces. Uses simplified raytracing. +/// +// could use RaycastSystem but the api it has isn't very amazing +public sealed class AreaEchoSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly MapSystem _map = default!; + [Dependency] private readonly Robust.Client.Physics.PhysicsSystem _physics = default!; + [Dependency] private readonly TransformSystem _transform = default!; + [Dependency] private readonly AudioEffectSystem _audioEffect = default!; + [Dependency] private readonly RoofSystem _roof = default!; + + /// + /// The directions that are raycasted to determine size for echo. + /// Used relative to the grid. + /// + private Angle[] _calculatedDirections = + [ + Direction.North.ToAngle(), + Direction.West.ToAngle(), + Direction.South.ToAngle(), + Direction.East.ToAngle(), + ]; + + /// + /// Values for the minimum arbitrary size at which a certain audio preset + /// is picked for sounds. The higher the highest distance here is, + /// the generally more calculations it has to do. + /// + /// + /// Keep in descending order. + /// + private static readonly (float Distance, ProtoId Preset)[] DistancePresets = + [ + (14f, "Hangar"), + (12f, "ConcertHall"), + (8f, "Auditorium"), + (3f, "Hallway"), + ]; + + private const float BounceDampening = 0.1f; + + /// + /// When is the next time we should check all audio entities and see if they are eligible to be updated. + /// + private TimeSpan _nextExistingUpdate = TimeSpan.Zero; + + /// + /// Collision mask for echoes. + /// + private const int EchoLayer = (int) (CollisionGroup.Opaque | CollisionGroup.Impassable); // this could be better but whatever + + private int _echoMaxReflections; + private bool _echoEnabled = true; + private TimeSpan _calculationInterval; // how often we should check existing audio re-apply or remove echo from them when necessary + private float _calculationalFidelity; + + private ConfigurationMultiSubscriptionBuilder _configSub = default!; + + private EntityQuery _gridQuery; + private EntityQuery _roofQuery; + + public override void Initialize() + { + base.Initialize(); + + UpdatesBefore.Add(typeof(AudioMuffleSystem)); + + _configSub = _cfg.SubscribeMultiple() + .OnValueChanged(ScpCCVars.EchoReflectionCount, x => _echoMaxReflections = x, invokeImmediately: true) + .OnValueChanged(ScpCCVars.EchoEnabled, x => _echoEnabled = x, invokeImmediately: true) + .OnValueChanged(ScpCCVars.EchoHighResolution, + x => _calculatedDirections = GetEffectiveDirections(x), + invokeImmediately: true) + .OnValueChanged(ScpCCVars.EchoRecalculationInterval, + x => _calculationInterval = TimeSpan.FromSeconds(x), + invokeImmediately: true) + .OnValueChanged(ScpCCVars.EchoStepFidelity, x => _calculationalFidelity = x, invokeImmediately: true); + + _gridQuery = GetEntityQuery(); + _roofQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnAudioParentChanged); + + Log.Level = LogLevel.Verbose; + } + + public override void Shutdown() + { + base.Shutdown(); + + _configSub.Dispose(); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + if (!_echoEnabled) + return; + + if (_timing.CurTime < _nextExistingUpdate) + return; + + _nextExistingUpdate = _timing.CurTime + _calculationInterval; + + var (minimumMagnitude, maximumMagnitude) = GetMinMax(); + + var audioEnumerator = AllEntityQuery(); + while (audioEnumerator.MoveNext(out var uid, out var audio, out var xform)) + { + if (audio.Global) + continue; + + if (!audio.Playing) + continue; + + ProcessAudioEntity((uid, audio), xform, minimumMagnitude, maximumMagnitude); + } + } + + /// + /// Returns all four cardinal directions when is false. + /// Otherwise, returns all eight intercardinal and cardinal directions as listed in + /// . + /// + [Pure] + public static Angle[] GetEffectiveDirections(bool highResolution) + { + if (!highResolution) + return [Direction.North.ToAngle(), Direction.West.ToAngle(), Direction.South.ToAngle(), Direction.East.ToAngle()]; + + var allDirections = DirectionExtensions.AllDirections; + var directions = new Angle[allDirections.Length]; + + for (var i = 0; i < allDirections.Length; i++) + { + directions[i] = allDirections[i].ToAngle(); + } + + return directions; + } + + /// + /// Takes an entity's . Goes through every parent it + /// has before reaching one that is a map. Returns the hierarchy + /// discovered, which includes the given . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List> TryGetHierarchyBeforeMap(Entity originEntity) + { + var hierarchy = new List>() { originEntity }; + + ref var currentEntity = ref originEntity; + ref var currentTransformComponent = ref currentEntity.Comp; + + var mapUid = currentEntity.Comp.MapUid; + + while (currentTransformComponent.ParentUid != mapUid /* break when the next entity is a map... */ && + currentTransformComponent.ParentUid.IsValid() /* ...or invalid */ ) + { + // iterate to next entity + var nextUid = currentTransformComponent.ParentUid; + currentEntity.Owner = nextUid; + currentTransformComponent = Transform(nextUid); + + hierarchy.Add(currentEntity); + } + + DebugTools.Assert(hierarchy.Count >= 1, "Malformed entity hierarchy! Hierarchy must always contain one element, but it doesn't. How did this happen?"); + return hierarchy; + } + + /// + /// Gets the length of the direction that reaches the furthest unobstructed + /// distance, in an attempt to get the size of the area. Aborts early + /// if either grid is missing or the tile isnt rooved. + /// + /// Returned magnitude is the longest valid length of the ray in each direction, + /// divided by the number of total processed angles. + /// + /// Whether anything was actually processed. + // i am the total overengineering guy... and this, is my code. + /* + This works under a few assumptions: + - An entity in space is invalid + - Any spaced tile is invalid + - Rays end on invalid tiles (space) or unrooved tiles, and dont process on separate grids. + - - This checked every `_calculationalFidelity`-ish tiles. Not precisely. But somewhere around that. Its moreso just proportional to that. + - Rays bounce. + */ + public bool TryProcessAreaSpaceMagnitude(Entity entity, float maximumMagnitude, out float magnitude) + { + magnitude = 0f; + var transformComponent = entity.Comp; + + // get either the grid or other parent entity this entity is on, and it's rotation + var entityHierarchy = TryGetHierarchyBeforeMap(entity); + if (entityHierarchy.Count <= 1) // hierarchy always starts with our entity. if it only has our entity, it means the next parent was the map, which we don't want + return false; // means this entity is in space/otherwise not on a grid + + // at this point, we know that we are somewhere on a grid + + // e.g.: if a sound is inside a crate, this will now be the grid the crate is on; if the sound is just on the grid, this will be the grid that the sound is on. + var entityGrid = entityHierarchy.Last(); + + // this is the last entity, or this entity itself, that this entity has, before the parent is a grid/map. e.g.: if a sound is inside a crate, this will be the crate; if the sound is just on the grid, this will be the sound + var lastEntityBeforeGrid = entityHierarchy[^2]; // `l[^x]` is analogous to `l[l.Count - x]` + // `lastEntityBeforeGrid` is obviously directly before `entityGrid` + // the earlier guard clause makes sure this will always be valid + + if (!_gridQuery.TryGetComponent(entityGrid, out var gridComponent)) + return false; + + var checkRoof = _roofQuery.TryGetComponent(entityGrid, out var roofComponent); + var tileRef = _map.GetTileRef(entityGrid, gridComponent, lastEntityBeforeGrid.Comp.Coordinates); + + if (tileRef.Tile.IsEmpty) + return false; + + var gridRoofEntity = new Entity(entityGrid, gridComponent, roofComponent); + if (checkRoof && !_roof.IsRooved(gridRoofEntity!, tileRef.GridIndices)) + return false; + + var originTileIndices = tileRef.GridIndices; + var worldPosition = _transform.GetWorldPosition(transformComponent); + + // At this point, we are ready for war against the client's pc. + foreach (var direction in _calculatedDirections) + { + var currentDirectionVector = direction.ToVec(); + var currentTargetEntityUid = lastEntityBeforeGrid.Owner; + + var totalDistance = 0f; + var remainingDistance = maximumMagnitude; + + var currentOriginWorldPosition = worldPosition; + var currentOriginTileIndices = originTileIndices; + + var currentAcousticEnergy = 1.0f; + + for (var reflectIteration = 0; reflectIteration <= _echoMaxReflections /* if maxreflections is 0 we still cast atleast once */; reflectIteration++) + { + var (distanceCovered, raycastResults) = CastEchoRay( + currentOriginWorldPosition, + currentOriginTileIndices, + currentDirectionVector, + transformComponent.MapID, + currentTargetEntityUid, + gridRoofEntity, + checkRoof, + remainingDistance + ); + + // Добавляем пройденную дистанцию с учетом оставшейся "энергии" звука + totalDistance += distanceCovered * currentAcousticEnergy; + + // Физический лимит луча отнимаем по-настоящему + remainingDistance -= distanceCovered; + + // we don't need further logic anyway if we just finished the last iteration + if (reflectIteration == _echoMaxReflections) + break; + + if (raycastResults is null) // means we didnt hit anything + break; + + currentAcousticEnergy *= BounceDampening; + + // i think cross-grid would actually be pretty easy here? but the tile-marching doesnt often account for that at fidelities above 1 so whatever. + + var previousRayWorldOriginPosition = currentOriginWorldPosition; + currentOriginWorldPosition = raycastResults.Value.HitPos; // it's now where we hit + currentTargetEntityUid = raycastResults.Value.HitEntity; + + // means tile that ray hit is invalid, just assume the ray ends here + if (!_map.TryGetTileRef(entityGrid, gridComponent, currentOriginWorldPosition, out var hitTileRef)) + break; + + currentOriginTileIndices = hitTileRef.GridIndices; + + var worldMatrix = _transform.GetInvWorldMatrix(gridRoofEntity); + var previousRayOriginLocalPosition = Vector2.Transform(previousRayWorldOriginPosition, worldMatrix); + var currentOriginLocalPosition = Vector2.Transform(currentOriginWorldPosition, worldMatrix); + + var delta = currentOriginLocalPosition - previousRayOriginLocalPosition; + if (delta.LengthSquared() <= float.Epsilon * 2) + break; + + var normalVector = GetTileHitNormal(currentOriginLocalPosition, _map.TileToVector(gridRoofEntity, currentOriginTileIndices), gridRoofEntity.Comp1.TileSize); + currentDirectionVector = Reflect(currentDirectionVector, normalVector); + } + + if (totalDistance > magnitude) + magnitude = totalDistance; + } + + return true; + } + + private static Vector2 GetTileHitNormal(Vector2 rayHitPos, Vector2 tileOrigin, float tileSize) + { + // Position inside the tile (0..tileSize) + var local = rayHitPos - tileOrigin; + + // Distances to each side + var left = local.X; + var right = tileSize - local.X; + var bottom = local.Y; + var top = tileSize - local.Y; + + // Find smallest distance + var minDist = MathF.Min(MathF.Min(left, right), MathF.Min(bottom, top)); + + if (MathHelper.CloseTo(minDist, left)) + return new Vector2(-1, 0); + if (MathHelper.CloseTo(minDist, right)) + return new Vector2(1, 0); + if (MathHelper.CloseTo(minDist, bottom)) + return new Vector2(0, -1); + + return new Vector2(0, 1); // must be top + } + + /// + /// should be normalised upon calling. + /// + [Pure] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector2 Reflect(in Vector2 direction, in Vector2 normal) + => direction - 2 * Vector2.Dot(direction, normal) * normal; + + // this caused vsc to spike to 28gb memory usage + /// + /// Casts a ray and marches it. See . + /// + private (float, RayCastResults?) CastEchoRay( + in Vector2 originWorldPosition, + in Vector2i originTileIndices, + in Vector2 directionVector, + in MapId mapId, + in EntityUid ignoredEntity, + in Entity gridRoofEntity, + bool checkRoof, + float maximumDistance + ) + { + var directionFidelityStep = directionVector * _calculationalFidelity; + + var ray = new CollisionRay(originWorldPosition, directionVector, EchoLayer); + var rayResults = _physics.IntersectRay(mapId, ray, maxLength: maximumDistance, ignoredEnt: ignoredEntity, returnOnFirstHit: true); + + // if we hit something, distance to that is magnitude but it must be lower than maximum. if we didnt hit anything, it's maximum magnitude + var rayMagnitude = rayResults.TryFirstOrNull(out var firstResult) + ? MathF.Min(firstResult.Value.Distance, maximumDistance) + : maximumDistance; + + var nextCheckedPosition = new Vector2(originTileIndices.X, originTileIndices.Y) * gridRoofEntity.Comp1.TileSize + directionFidelityStep; + var incrementedRayMagnitude = MarchRayByTiles( + rayMagnitude, + gridRoofEntity, + directionFidelityStep, + ref nextCheckedPosition, + gridRoofEntity.Comp1.TileSize, + checkRoof + ); + + return (incrementedRayMagnitude, firstResult); + } + + /// + /// Advances a ray, in intervals of `_calculationalFidelity`, by tiles until + /// reaching an unrooved tile (if checking roofs) or space. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private float MarchRayByTiles( + in float rayMagnitude, + in Entity gridRoofEntity, + in Vector2 directionFidelityStep, + ref Vector2 nextCheckedPosition, + ushort gridTileSize, + bool checkRoof + ) + { + // find the furthest distance this ray reaches until its on an unrooved/dataless (space) tile + + var fidelityStepLength = directionFidelityStep.Length(); + var incrementedRayMagnitude = 0f; + + for (; incrementedRayMagnitude < rayMagnitude;) + { + var nextCheckedTilePosition = new Vector2i( + (int) MathF.Floor(nextCheckedPosition.X / gridTileSize), + (int) MathF.Floor(nextCheckedPosition.Y / gridTileSize) + ); + + if (checkRoof) + { + // if we're checking roofs, end this ray if this tile is unrooved or dataless (latter is inherent of this method) + if (!_roof.IsRooved(gridRoofEntity!, nextCheckedTilePosition)) + break; + } + // if we're not checking roofs, end this ray if this tile is empty/space + else if (!_map.TryGetTileRef(gridRoofEntity, gridRoofEntity, nextCheckedTilePosition, out var tile) || + tile.Tile.IsEmpty) + break; + + nextCheckedPosition += directionFidelityStep; + incrementedRayMagnitude += fidelityStepLength; + } + + return MathF.Min(incrementedRayMagnitude, rayMagnitude); + } + + private void ProcessAudioEntity(Entity entity, TransformComponent transformComponent, float minimumMagnitude, float maximumMagnitude) + { + TryProcessAreaSpaceMagnitude((entity, transformComponent), maximumMagnitude, out var echoMagnitude); + + if (echoMagnitude <= minimumMagnitude) + { + _audioEffect.TryRemoveEffect(entity); + return; + } + + if (!TryGetPreset(echoMagnitude, out var bestPreset)) + { + _audioEffect.TryRemoveEffect(entity); + return; + } + + Log.Debug($"Used preset {bestPreset.Value}"); + _audioEffect.TryAddEffect(entity, bestPreset.Value); + } + + private bool TryGetPreset(float echoMagnitude, [NotNullWhen(true)] out ProtoId? bestPreset) + { + bestPreset = null; + + foreach (var (distance, preset) in DistancePresets) + { + if (echoMagnitude < distance) + continue; + + bestPreset = preset; + return true; + } + + return false; + } + + // Maybe TODO: defer this onto ticks? but whatever its just clientside + private void OnAudioParentChanged(Entity ent, ref EntParentChangedMessage args) + { + if (!_echoEnabled) + return; + + if (ent.Comp.Global) + return; + + if (args.Transform.MapID == MapId.Nullspace) + return; + + var (minimumMagnitude, maximumMagnitude) = GetMinMax(); + + ProcessAudioEntity(ent, args.Transform, minimumMagnitude, maximumMagnitude); + } + + private static (float Min, float Max) GetMinMax() + { + var minimumMagnitude = DistancePresets.Last().Distance; + DebugTools.Assert(minimumMagnitude > 0f, "Minimum distance must be greater than zero!"); + + var maximumMagnitude = DistancePresets.First().Distance; + DebugTools.Assert(maximumMagnitude > 0f, "Maximum distance must be greater than zero!"); + + return (minimumMagnitude, maximumMagnitude); + } +} diff --git a/Content.Client/_Scp/Audio/AudioEffectSystem.cs b/Content.Client/_Scp/Audio/AudioEffectSystem.cs new file mode 100644 index 00000000000..5b73e7e8d95 --- /dev/null +++ b/Content.Client/_Scp/Audio/AudioEffectSystem.cs @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: 2025 LaCumbiaDelCoronavirus +// SPDX-FileCopyrightText: 2025 ark1368 +// +// SPDX-License-Identifier: MPL-2.0 + +// some parts taken and modified from https://github.com/TornadoTechnology/finster/blob/1af5daf6270477a512ee9d515371311443e97878/Content.Shared/_Finster/Audio/SharedAudioEffectsSystem.cs#L13 , credit to docnite +// they're under WTFPL so its quite allowed + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Content.Shared.GameTicking; +using Robust.Client.Audio; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Components; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using DependencyAttribute = Robust.Shared.IoC.DependencyAttribute; + +namespace Content.Client._Scp.Audio; + +/// +/// Handler for client-side audio effects. +/// +public sealed class AudioEffectSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly AudioSystem _audioSystem = default!; + + /// + /// Whether creating new auxiliaries is safe. + /// This is done because integration tests + /// apparently can't handle them. + /// + /// Null means this value wasn't determined yet. + /// + /// Any not-null value here is the final result, + /// and no more attempts to determine this + /// will be made afterwards. + /// + // actually this problem applies for effects too + private bool? _auxiliariesSafe = null; + + private static readonly Dictionary, (EntityUid AuxiliaryUid, EntityUid EffectUid)> CachedEffects = new(); + + /// + /// An auxiliary with no effect; for removing effects. + /// + // TODO: remove this when an rt method to actually remove effects gets added + private EntityUid _cachedBlankAuxiliaryUid; + + public override void Initialize() + { + base.Initialize(); + + // You can't keep references to this past round-end so it must be cleaned up. + SubscribeNetworkEvent(_ => Cleanup()); // its not raised on client + SubscribeLocalEvent(OnPrototypeReload); + } + + public override void Shutdown() + { + base.Shutdown(); + Cleanup(); + } + + private void OnPrototypeReload(PrototypesReloadedEventArgs args) + { + if (!args.WasModified()) + return; + + // get rid of all old cached entities, and replace them with new ones + var oldPresets = new List>(); + foreach (var cache in CachedEffects) + { + oldPresets.Add(cache.Key); + + TryQueueDel(cache.Value.AuxiliaryUid); + TryQueueDel(cache.Value.EffectUid); + } + CachedEffects.Clear(); + + foreach (var oldPreset in oldPresets) + { + if (!ResolveCachedEffect(oldPreset, out var cachedAuxiliaryUid, out var cachedEffectUid)) + continue; + + CachedEffects[oldPreset] = (cachedAuxiliaryUid.Value, cachedEffectUid.Value); + } + } + + private void Cleanup() + { + foreach (var cache in CachedEffects) + { + TryQueueDel(cache.Value.AuxiliaryUid); + TryQueueDel(cache.Value.EffectUid); + } + CachedEffects.Clear(); + + if (_cachedBlankAuxiliaryUid.IsValid()) + TryQueueDel(_cachedBlankAuxiliaryUid); + + _cachedBlankAuxiliaryUid = EntityUid.Invalid; + } + + + /// + /// Figures out whether auxiliaries are safe to use. Returns + /// whether a safe auxiliary pair has been outputted + /// for use. + /// + private bool DetermineAuxiliarySafety([NotNullWhen(true)] out (EntityUid Entity, AudioAuxiliaryComponent Component)? auxiliaryPair, bool destroyPairAfterUse = true) + { + (EntityUid Entity, AudioAuxiliaryComponent Component)? maybeAuxiliaryPair = null; + try + { + maybeAuxiliaryPair = _audioSystem.CreateAuxiliary(); + _auxiliariesSafe = true; + } + catch (Exception ex) + { + Log.Info($"Determined audio auxiliaries are unsafe in this run! If this is not an integration test, report this immediately. Exception: {ex}"); + _auxiliariesSafe = false; + + TryQueueDel(maybeAuxiliaryPair?.Entity); + + auxiliaryPair = null; + return false; + } + + if (destroyPairAfterUse) + { + QueueDel(maybeAuxiliaryPair.Value.Entity); + + auxiliaryPair = null; + return false; + } + + auxiliaryPair = maybeAuxiliaryPair.Value; + return true; + } + + /// + /// Returns whether auxiliaries are definitely safe to use. + /// Determines auxiliary safety if not already. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool AuxiliariesAreDefinitelySafe() + { + if (_auxiliariesSafe == null) + DetermineAuxiliarySafety(out _, destroyPairAfterUse: true); + + return _auxiliariesSafe == true; + } + + /// + /// Tries to resolve a cached audio auxiliary entity corresponding to the prototype to apply + /// to the given entity. + /// + public bool TryAddEffect(in Entity entity, in ProtoId preset) + { + if (!AuxiliariesAreDefinitelySafe() || + !ResolveCachedEffect(preset, out var auxiliaryUid, out _)) + return false; + + _audioSystem.SetAuxiliary(entity, entity.Comp, auxiliaryUid); + return true; + } + + /// + /// Tries to remove effects from the given audio. Returns whether the attempt was successful, + /// or no auxiliary is applied to the audio. + /// + public bool TryRemoveEffect(in Entity entity) + { + if (!AuxiliariesAreDefinitelySafe()) + return false; + + if (entity.Comp.Auxiliary is not { } existingAuxiliaryUid || + !existingAuxiliaryUid.IsValid()) + return true; + + // resolve the cached auxiliary + if (!_cachedBlankAuxiliaryUid.IsValid()) + _cachedBlankAuxiliaryUid = _audioSystem.CreateAuxiliary().Entity; + + _audioSystem.SetAuxiliary(entity, entity.Comp, _cachedBlankAuxiliaryUid); + return true; + } + + /// + /// Tries to resolve an audio auxiliary and effect entity, creating and caching one if one doesn't already exist, + /// for a prototype. Do not modify it in any way. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ResolveCachedEffect(in ProtoId preset, [NotNullWhen(true)] out EntityUid? auxiliaryUid, [NotNullWhen(true)] out EntityUid? effectUid) + { + if (_auxiliariesSafe == false) + { + auxiliaryUid = null; + effectUid = null; + return false; + } + + if (CachedEffects.TryGetValue(preset, out var cached)) + { + auxiliaryUid = cached.AuxiliaryUid; + effectUid = cached.EffectUid; + return true; + } + + return TryCacheEffect(preset, out auxiliaryUid, out effectUid); + } + + /// + /// Tries to initialise and cache effect and auxiliary entities corresponding to a prototype, + /// in the system's internal cache. + /// + /// Does nothing if the entity already exists in the cache. + /// + /// Whether the entity was successfully initialised, and it did not previously exist in the cache. + public bool TryCacheEffect(in ProtoId preset, [NotNullWhen(true)] out EntityUid? auxiliaryUid, [NotNullWhen(true)] out EntityUid? effectUid) + { + + effectUid = null; + auxiliaryUid = null; + + if (_auxiliariesSafe == false || + !_prototypeManager.TryIndex(preset, out var presetPrototype)) + return false; + + // i cant `??=` it + (EntityUid Entity, AudioAuxiliaryComponent Component)? maybeAuxiliaryPair = null; + + // if undetermined, determine and keep the pair if confirmed safe + if (_auxiliariesSafe == null) + { + // if determined unsafe, cleanup the pair + if (!DetermineAuxiliarySafety(out maybeAuxiliaryPair, destroyPairAfterUse: false)) + return false; + } + + // now, auxiliaries are known to be safe. + // only when initially determining if auxiliaries are safe will we have a pair to use. in future attempts, we won't so just make one if necessary + var auxiliaryPair = maybeAuxiliaryPair ?? _audioSystem.CreateAuxiliary(); + + DebugTools.Assert(Exists(auxiliaryPair.Entity), "Audio auxiliary pair's entity does not exist!"); + if (!Exists(auxiliaryPair.Entity)) + return false; + + var effectPair = _audioSystem.CreateEffect(); + _audioSystem.SetEffectPreset(effectPair.Entity, effectPair.Component, presetPrototype); + _audioSystem.SetEffect(auxiliaryPair.Entity, auxiliaryPair.Component, effectPair.Entity); + + effectUid = effectPair.Entity; + auxiliaryUid = auxiliaryPair.Entity; + + return CachedEffects.TryAdd(preset, (auxiliaryPair.Entity, effectPair.Entity)); + } +} diff --git a/Content.Client/_Scp/Audio/EchoEffectSystem.cs b/Content.Client/_Scp/Audio/EchoEffectSystem.cs deleted file mode 100644 index c35337ec66a..00000000000 --- a/Content.Client/_Scp/Audio/EchoEffectSystem.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Content.Shared._Scp.Audio; -using Content.Shared._Scp.Audio.Components; -using Content.Shared._Scp.ScpCCVars; -using Content.Client._Scp.Audio.Components; -using Robust.Shared.Audio; -using Robust.Shared.Audio.Components; -using Robust.Shared.Audio.Systems; -using Robust.Shared.Configuration; -using Robust.Shared.Prototypes; -using Robust.Shared.Timing; - -namespace Content.Client._Scp.Audio; - -/// -/// Система, накладывающая эффект эхо каждому неглобальному звуку. -/// Эффект может быть отключен игроком в настройках -/// -public sealed class EchoEffectSystem : EntitySystem -{ - [Dependency] private readonly AudioEffectsManagerSystem _effectsManager = default!; - [Dependency] private readonly IGameTiming _timing = default!; - [Dependency] private readonly IConfigurationManager _cfg = default!; - - private static readonly ProtoId StandardEchoEffectPreset = "Bathroom"; - private static readonly ProtoId StrongEchoEffectPreset = "SewerPipe"; - - private bool _isClientSideEnabled; - private bool _strongPresetPreferred; - - public override void Initialize() - { - base.Initialize(); - - SubscribeLocalEvent(OnAudioAdd); - SubscribeLocalEvent(OnEffectedAudioStartup, after: [typeof(SharedAudioSystem)]); - - _isClientSideEnabled = _cfg.GetCVar(ScpCCVars.EchoEnabled); - _strongPresetPreferred = _cfg.GetCVar(ScpCCVars.EchoStrongPresetPreferred); - - _cfg.OnValueChanged(ScpCCVars.EchoEnabled, OnEnabledToggled); - _cfg.OnValueChanged(ScpCCVars.EchoStrongPresetPreferred, OnPreferredPresetToggled); - } - - public override void Shutdown() - { - base.Shutdown(); - - _cfg.UnsubValueChanged(ScpCCVars.EchoEnabled, OnEnabledToggled); - _cfg.UnsubValueChanged(ScpCCVars.EchoStrongPresetPreferred, OnPreferredPresetToggled); - } - - private void OnAudioAdd(Entity ent, ref ComponentAdd args) - { - if (!_isClientSideEnabled) - return; - - EnsureComp(ent); - } - - private void OnEffectedAudioStartup(Entity ent, ref ComponentStartup args) - { - if (!_isClientSideEnabled) - return; - - if (!TryComp(ent.Owner, out var audio)) - return; - - TryApplyEcho((ent.Owner, audio)); - } - - /// - /// Пытается применить эхо к данном звуку - /// - /// Звук, к которому будет применен эффект - /// Пресет, если нужно выставить какой-то особенный - /// Получилось или не получилось применить эффект - public bool TryApplyEcho(Entity sound, ProtoId? preset = null) - { - if (TerminatingOrDeleted(sound) || Paused(sound)) - return false; - - // Фоновая музыка не должна подвергаться эффектам эха - if (sound.Comp.Global) - return false; - - // Выбираем пресет для эха исходя из настроек игрока и возможного приоритетного эффекта при вызове извне системы - var clientPreferredPreset = _strongPresetPreferred ? StrongEchoEffectPreset : StandardEchoEffectPreset; - var targetPreset = preset ?? clientPreferredPreset; - - _effectsManager.TryAddEffect(sound, targetPreset); - - // Добавляем компонент-маркер к звуку, который будет хранить эффект эха - var echoComp = EnsureComp(sound); - echoComp.Preset = targetPreset; - - return true; - } - - /// - /// Пытается убрать эффект эхо у выбранного звука - /// - public bool TryRemoveEcho(Entity sound, AudioEchoEffectAffectedComponent? echoComp = null) - { - if (!Resolve(sound, ref echoComp)) - return false; - - if (!_effectsManager.TryRemoveEffect(sound, echoComp.Preset)) - return false; - - RemComp(sound); - RemComp(sound); - - return true; - } - - private void OnEnabledToggled(bool enabled) - { - _isClientSideEnabled = enabled; - - if (!enabled) - RevertChanges(); - } - - private void OnPreferredPresetToggled(bool useStrong) - { - _strongPresetPreferred = useStrong; - var newPreferredPreset = useStrong ? StrongEchoEffectPreset : StandardEchoEffectPreset; - - TogglePreset(newPreferredPreset); - } - - /// - /// Убирает эффекты эхо у всех звуков, что имеют его. - /// Вызывается при выключении эффекта эха игроком. - /// - private void RevertChanges() - { - var query = AllEntityQuery(); - - while (query.MoveNext(out var uid, out var echoComp, out var audio)) - { - TryRemoveEcho((uid, audio), echoComp); - } - } - - private void TogglePreset(ProtoId newPreferredPreset) - { - var query = AllEntityQuery(); - - while (query.MoveNext(out var uid, out var echoComp, out var audio)) - { - if (!TryRemoveEcho((uid, audio), echoComp)) - continue; - - TryApplyEcho((uid, audio), newPreferredPreset); - } - } -} diff --git a/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml b/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml index d7b97d9a7c1..770c343069a 100644 --- a/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml +++ b/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml @@ -61,9 +61,16 @@ - + + + + - /// Будет ли использован эффект эхо? + /// Whether to render sounds with echo when they are in 'large' open, rooved areas. /// + /// public static readonly CVarDef EchoEnabled = - CVarDef.Create("scp.echo_enabled", true, CVar.CLIENTONLY | CVar.ARCHIVE); + CVarDef.Create("scp.audio.area_echo.enabled", true, CVar.ARCHIVE | CVar.CLIENTONLY); /// - /// Будет ли использован пресет сильного эха? + /// If false, area echos calculate with 4 directions (NSEW). + /// Otherwise, area echos calculate with all 8 directions. /// - public static readonly CVarDef EchoStrongPresetPreferred = - CVarDef.Create("scp.echo_strong_preset_preferred", false, CVar.CLIENTONLY | CVar.ARCHIVE); + /// + public static readonly CVarDef EchoHighResolution = + CVarDef.Create("scp.audio.area_echo.alldirections", false, CVar.ARCHIVE | CVar.CLIENTONLY); - /** - * Подавление звуков - */ + + /// + /// How many times a ray can bounce off a surface for an echo calculation. + /// + /// + public static readonly CVarDef EchoReflectionCount = + CVarDef.Create("scp.audio.area_echo.max_reflections", 1, CVar.ARCHIVE | CVar.CLIENTONLY); + + /// + /// Distantial interval, in tiles, in the rays used to calculate the roofs of an open area for echos, + /// or the ray's distance to space, at which the tile at that point of the ray is processed. + /// + /// The lower this is, the more 'predictable' and computationally heavy the echoes are. + /// + /// + public static readonly CVarDef EchoStepFidelity = + CVarDef.Create("scp.audio.area_echo.step_fidelity", 5f, CVar.CLIENTONLY); + + /// + /// Interval between updates for every audio entity. + /// + /// + public static readonly CVarDef EchoRecalculationInterval = + CVarDef.Create("scp.audio.area_echo.recalculation_interval", 1, CVar.ARCHIVE | CVar.CLIENTONLY); + + #endregion + + #region Muffle /// /// Будет ли подавление звуков в зависимости от видимости работать? @@ -35,4 +61,6 @@ public sealed partial class ScpCCVars /// public static readonly CVarDef AudioMufflingHighFrequencyUpdate = CVarDef.Create("scp.audio_muffling_use_high_frequency_update", false, CVar.CLIENTONLY | CVar.ARCHIVE); + + #endregion }