diff --git a/Content.Client/Options/UI/Tabs/ExtraTab.xaml b/Content.Client/Options/UI/Tabs/ExtraTab.xaml
index 19348d4d4f..fa995d2455 100644
--- a/Content.Client/Options/UI/Tabs/ExtraTab.xaml
+++ b/Content.Client/Options/UI/Tabs/ExtraTab.xaml
@@ -31,6 +31,7 @@
+
diff --git a/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs b/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
index 938a26cd53..acc4fbc7e0 100644
--- a/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
@@ -43,6 +43,8 @@ public sealed partial class ExtraTab : Control
Control.AddOptionCheckBox(SunriseCCVars.VoteMusicDisable, VoteMusicDisableCheckBox);
Control.AddOptionCheckBox(SunriseCCVars.MuteGhostRoleNotification, MuteGhostRoleNotificationCheckBox);
+ Control.AddOptionCheckBox(SunriseCCVars.PlayHeartBeatSound, PlayHeartbeatSound);
+
_cfg.OnValueChanged(SunriseCCVars.LobbyBackgroundType, OnLobbyBackgroundTypeChanged, true);
var lobbyBackgroundTypes = new List.ValueOption>
diff --git a/Content.Client/_Sunrise/Heartbeat/HeartbeatSystem.Settings.cs b/Content.Client/_Sunrise/Heartbeat/HeartbeatSystem.Settings.cs
new file mode 100644
index 0000000000..e0685809bb
--- /dev/null
+++ b/Content.Client/_Sunrise/Heartbeat/HeartbeatSystem.Settings.cs
@@ -0,0 +1,29 @@
+using Content.Shared._Sunrise.Heartbeat;
+using Content.Shared._Sunrise.SunriseCCVars;
+using Robust.Shared.Configuration;
+
+namespace Content.Client._Sunrise.Heartbeat;
+
+public sealed class HeartbeatSystem : EntitySystem
+{
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _cfg.OnValueChanged(SunriseCCVars.PlayHeartBeatSound, OnOptionsChanged, true);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ _cfg.UnsubValueChanged(SunriseCCVars.PlayHeartBeatSound, OnOptionsChanged);
+ }
+
+ private void OnOptionsChanged(bool option)
+ {
+ RaiseNetworkEvent(new HeartbeatOptionsChangedEvent(option));
+ }
+}
diff --git a/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatComponent.cs b/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatComponent.cs
deleted file mode 100644
index b4a62e87a6..0000000000
--- a/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatComponent.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Robust.Shared.Audio;
-
-namespace Content.Server._Sunrise.CritHeartbeat;
-
-[RegisterComponent]
-public sealed partial class CritHeartbeatComponent : Component
-{
- [DataField]
- public SoundSpecifier HeartbeatSound = new SoundPathSpecifier("/Audio/_Sunrise/Effects/heartbeat.ogg");
-
- ///
- /// Чтобы выключать это для наследников в прототипах
- ///
- [DataField]
- public bool Enabled = true;
-
- public EntityUid? AudioStream;
-}
diff --git a/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatSystem.cs b/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatSystem.cs
deleted file mode 100644
index dfa89d6684..0000000000
--- a/Content.Server/_Sunrise/CritHeartbeat/CritHeartbeatSystem.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using Content.Shared.Damage;
-using Content.Shared.Mobs;
-using Robust.Server.Audio;
-using Robust.Shared.Audio;
-
-namespace Content.Server._Sunrise.CritHeartbeat;
-
-public sealed class CritHeartbeatSystem : EntitySystem
-{
- [Dependency] private readonly AudioSystem _audio = default!;
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnMobStateChanged);
- SubscribeLocalEvent(OnDamage);
- }
-
- private void OnMobStateChanged(Entity ent, ref MobStateChangedEvent args)
- {
- if (!ent.Comp.Enabled)
- return;
-
- ent.Comp.AudioStream = args.NewMobState == MobState.Critical
- ? _audio.PlayEntity(ent.Comp.HeartbeatSound, ent, ent)?.Entity
- : _audio.Stop(ent.Comp.AudioStream);
- }
-
- private void OnDamage(Entity ent, ref DamageChangedEvent args)
- {
- if (!ent.Comp.Enabled)
- return;
-
- if (!Exists(ent.Comp.AudioStream))
- return;
-
- var pitch = Math.Min(1, 100 / args.Damageable.TotalDamage.Float());
-
- // Потому что игра говно, тут нельзя изменять аудиопарамс уже существующего звука. Поэтому я пересоздаю его заново
- // Это приводит к проигрыванию звука через неравномерные промежутки времени, но зато работает и не очень заметно
- _audio.Stop(ent.Comp.AudioStream);
- ent.Comp.AudioStream = _audio.PlayEntity(ent.Comp.HeartbeatSound, ent, ent, AudioParams.Default.WithPitchScale(pitch))?.Entity;
- }
-}
diff --git a/Content.Server/_Sunrise/Heartbeat/Components/ActiveHeartbeatComponent.cs b/Content.Server/_Sunrise/Heartbeat/Components/ActiveHeartbeatComponent.cs
new file mode 100644
index 0000000000..389d572da5
--- /dev/null
+++ b/Content.Server/_Sunrise/Heartbeat/Components/ActiveHeartbeatComponent.cs
@@ -0,0 +1,10 @@
+namespace Content.Server._Sunrise.Heartbeat.Components;
+
+[RegisterComponent]
+public sealed partial class ActiveHeartbeatComponent : Component
+{
+ [ViewVariables] public float Pitch = 1f;
+ [ViewVariables] public TimeSpan NextHeartbeatCooldown = TimeSpan.FromSeconds(0.5f);
+
+ public TimeSpan? NextHeartbeatTime;
+}
diff --git a/Content.Server/_Sunrise/Heartbeat/Components/CritHeartbeatComponent.cs b/Content.Server/_Sunrise/Heartbeat/Components/CritHeartbeatComponent.cs
new file mode 100644
index 0000000000..593c370a38
--- /dev/null
+++ b/Content.Server/_Sunrise/Heartbeat/Components/CritHeartbeatComponent.cs
@@ -0,0 +1,4 @@
+namespace Content.Server._Sunrise.Heartbeat.Components;
+
+[RegisterComponent]
+public sealed partial class CritHeartbeatComponent : Component;
diff --git a/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.Crit.cs b/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.Crit.cs
new file mode 100644
index 0000000000..66ff9f4107
--- /dev/null
+++ b/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.Crit.cs
@@ -0,0 +1,60 @@
+using Content.Server._Sunrise.Heartbeat.Components;
+using Content.Shared.Damage;
+using Content.Shared.Mobs;
+
+namespace Content.Server._Sunrise.Heartbeat.Systems;
+
+public sealed partial class HeartbeatSystem
+{
+ // Минимальное и максимальное время между ударами сердца
+ private const float MinimumCooldown = 0.5f;
+ private const float MaximumCooldown = 3f;
+
+ private void OnMobStateChanged(Entity ent, ref MobStateChangedEvent args)
+ {
+ if (args.NewMobState != MobState.Critical)
+ {
+ RemComp(ent);
+ return;
+ }
+
+ var activeHeartbeat = EnsureComp(ent);
+
+ TryCalculateCurrentState((ent.Owner, activeHeartbeat));
+ SetNextTime(activeHeartbeat);
+ }
+
+ ///
+ /// Подтягивает значения эффектов в зависимости от того, насколько игрок продамажен
+ /// Чем выше урон -> тем медленнее бьется сердце и тем более глухой звук
+ ///
+ private void OnDamage(Entity ent, ref DamageChangedEvent args)
+ {
+ TryCalculateCurrentState(ent, args.Damageable);
+ }
+
+ ///
+ /// Подсчитывает нужные данные о текущем уроне тела и в зависимости от них задает нужный pitch и cooldown для сердцебиения
+ ///
+ ///
+ ///
+ ///
+ private bool TryCalculateCurrentState(Entity ent, DamageableComponent? damageable = null)
+ {
+ if (!Resolve(ent.Owner, ref damageable))
+ return false;
+
+ var totalDamage = damageable.TotalDamage.Float();
+
+ var pitch = Math.Min(1f, 100f / totalDamage);
+
+ var excess = Math.Max(0f, totalDamage - 100f);
+ var cooldownSeconds = MinimumCooldown + (excess / 100f) * (MaximumCooldown - MinimumCooldown);
+
+ ent.Comp.Pitch = pitch;
+ ent.Comp.NextHeartbeatCooldown = TimeSpan.FromSeconds(cooldownSeconds);
+
+ return true;
+ }
+
+}
diff --git a/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.cs b/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.cs
new file mode 100644
index 0000000000..34cf06e212
--- /dev/null
+++ b/Content.Server/_Sunrise/Heartbeat/Systems/HeartbeatSystem.cs
@@ -0,0 +1,84 @@
+using Content.Server._Sunrise.Heartbeat.Components;
+using Content.Shared._Sunrise.Heartbeat;
+using Content.Shared.Damage;
+using Content.Shared.GameTicking;
+using Content.Shared.Mobs;
+using Robust.Server.Audio;
+using Robust.Shared.Audio;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+
+namespace Content.Server._Sunrise.Heartbeat.Systems;
+
+// TODO: Сделать возможность с помощью стетоскопа услышать сердцебиение человека
+
+public sealed partial class HeartbeatSystem : EntitySystem
+{
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly ISharedPlayerManager _player = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private static readonly SoundSpecifier HeartbeatSound =
+ new SoundPathSpecifier("/Audio/_Sunrise/Effects/heartbeat.ogg", AudioParams.Default.WithVolume(-3f));
+
+ private static readonly HashSet DisabledSessions = [];
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnMobStateChanged);
+ SubscribeLocalEvent(OnDamage);
+
+ SubscribeNetworkEvent(OnOptionsChanged);
+
+ SubscribeLocalEvent(_ => DisabledSessions.Clear());
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var activeHeartbeat))
+ {
+ if (_timing.CurTime < activeHeartbeat.NextHeartbeatTime)
+ continue;
+
+ if (IsDisabledByClient(uid))
+ continue;
+
+ _audio.PlayGlobal(HeartbeatSound, uid, AudioParams.Default.WithPitchScale(activeHeartbeat.Pitch));
+
+ SetNextTime(activeHeartbeat);
+ }
+ }
+
+ ///
+ /// Устанавливает время следующего удара сердца
+ ///
+ private void SetNextTime(ActiveHeartbeatComponent component)
+ {
+ component.NextHeartbeatTime = _timing.CurTime + component.NextHeartbeatCooldown;
+ }
+
+ private bool IsDisabledByClient(EntityUid player)
+ {
+ if (!_player.TryGetSessionByEntity(player, out var session))
+ return true;
+
+ if (DisabledSessions.Contains(session))
+ return true;
+
+ return false;
+ }
+
+ private static void OnOptionsChanged(HeartbeatOptionsChangedEvent ev, EntitySessionEventArgs args)
+ {
+ if (ev.Enabled)
+ DisabledSessions.Remove(args.SenderSession);
+ else
+ DisabledSessions.Add(args.SenderSession);
+ }
+}
diff --git a/Content.Shared/_Sunrise/Heartbeat/HeartbeatOptionsChangedEvent.cs b/Content.Shared/_Sunrise/Heartbeat/HeartbeatOptionsChangedEvent.cs
new file mode 100644
index 0000000000..8485e63bdc
--- /dev/null
+++ b/Content.Shared/_Sunrise/Heartbeat/HeartbeatOptionsChangedEvent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Sunrise.Heartbeat;
+
+[Serializable, NetSerializable]
+public sealed class HeartbeatOptionsChangedEvent(bool enabled) : EntityEventArgs
+{
+ public bool Enabled { get; } = enabled;
+}
diff --git a/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs b/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
index 338bc5e79e..e38597cfc3 100644
--- a/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
+++ b/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
@@ -428,4 +428,11 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef MuteGhostRoleNotification =
CVarDef.Create("ghost.mute_role_notification", false, CVar.CLIENTONLY | CVar.ARCHIVE);
+
+ /*
+ * Heartbeat sound
+ */
+
+ public static readonly CVarDef PlayHeartBeatSound =
+ CVarDef.Create("heartbeat.play_sound", true, CVar.CLIENTONLY | CVar.ARCHIVE);
}
diff --git a/Resources/Locale/ru-RU/_strings/_sunrise/escape-menu/ui/options-menu.ftl b/Resources/Locale/ru-RU/_strings/_sunrise/escape-menu/ui/options-menu.ftl
index cb8a05707c..e32e96de21 100644
--- a/Resources/Locale/ru-RU/_strings/_sunrise/escape-menu/ui/options-menu.ftl
+++ b/Resources/Locale/ru-RU/_strings/_sunrise/escape-menu/ui/options-menu.ftl
@@ -24,3 +24,4 @@ ui-options-function-auto-get-up = Автоматически вставать п
ui-options-function-hold-look-up = Удерживать клавишу для прицеливания
ui-options-chat-icons-enable = Использовать иконки профессий в чате
ui-options-chat-pointing-visuals-enable = Отображать указывания с иконками в чате
+ui-options-play-heartbeat-sound = Проигрывать звук сердцебиения
diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml
index 349d143630..83149e6328 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/base.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml
@@ -270,11 +270,6 @@
- type: CanEscapeInventory
- type: Mood
- type: CritHeartbeat
- heartbeatSound:
- path: /Audio/_Sunrise/Effects/heartbeat.ogg
- params:
- volume: -3
- loop: True
# Sunrise-End
- type: Barotrauma
damage: