diff --git a/Immersion/Patches/LowOxygenAlertPatches.cs b/Immersion/Patches/LowOxygenAlertPatches.cs index 940d81c5..f8561d2a 100644 --- a/Immersion/Patches/LowOxygenAlertPatches.cs +++ b/Immersion/Patches/LowOxygenAlertPatches.cs @@ -1,5 +1,3 @@ -using System.Reflection; -using System.Reflection.Emit; using Immersion.Trackers; namespace Immersion.Patches; @@ -7,49 +5,10 @@ namespace Immersion.Patches; [HarmonyPatch] public static class LowOxygenAlertPatches { - public static bool PatchSucceeded { get; private set; } - - [HarmonyPatch(typeof(LowOxygenAlert), nameof(LowOxygenAlert.Update))] - [HarmonyTranspiler] - private static IEnumerable Patch(IEnumerable instructions) - { - CodeMatcher matcher = new(instructions); - - FieldInfo notifField = typeof(LowOxygenAlert.Alert).GetField(nameof(LowOxygenAlert.Alert.notification)); - MethodInfo playMethod = typeof(PDANotification).GetMethod(nameof(PDANotification.Play), []); - matcher.MatchForward(false, - new CodeMatch(OpCodes.Ldloc_2), - new CodeMatch(OpCodes.Ldfld, notifField), - new CodeMatch(OpCodes.Callvirt, playMethod) - ); - if (!matcher.IsValid) - { - LOGGER.LogWarning($"Could not patch {nameof(LowOxygenAlert)}.{nameof(LowOxygenAlert.Update)}, {nameof(OxygenAlerts)} will not function"); - return instructions; - } - matcher.Advance(1); - matcher.RemoveInstructions(2); - matcher.Insert( - CodeInstruction.Call((LowOxygenAlert.Alert alert) => PlayAlert(alert)) - ); - - PatchSucceeded = true; - LOGGER.LogDebug($"Oxygen alerts patched for {nameof(OxygenAlerts)}"); - return matcher.InstructionEnumeration(); - } - [HarmonyPatch(typeof(HintSwimToSurface), nameof(HintSwimToSurface.ShouldShowWarning))] [HarmonyPostfix] private static void DisableHint(ref bool __result) { __result &= !(OxygenAlerts.Instance && OxygenAlerts.Instance.enabled); } - - private static void PlayAlert(LowOxygenAlert.Alert alert) - { - if (alert is OxygenAlerts.Alert ourAlert) - ourAlert.Play(); - else - alert.notification.Play(); - } } diff --git a/Immersion/Trackers/OxygenAlerts.cs b/Immersion/Trackers/OxygenAlerts.cs index ba17cb6e..d40d14a0 100644 --- a/Immersion/Trackers/OxygenAlerts.cs +++ b/Immersion/Trackers/OxygenAlerts.cs @@ -10,10 +10,11 @@ namespace Immersion.Trackers; /// public sealed class OxygenAlerts : Tracker { - private List _ourAlerts; + private List _ourAlerts; private List _gameAlerts; private LowOxygenAlert _alerts; + private Player _player; public static OxygenAlerts Instance { get; private set; } @@ -22,19 +23,34 @@ protected override void Awake() Instance = this; base.Awake(); _ourAlerts = [ - new Alert(this, "{player} has under 30 seconds of oxygen remaining.") { + new Alert { + Message = "{player}'s oxygen tank is under half capacity.", + Priority = Priority.Low, minDepth = 50, // base game's 30s alert has a min depth of 30 minO2Capacity = 60, notification = null, - oxygenTriggerSeconds = 30, + oxygenTriggerSeconds = 0, // originally 30s, changed to trigger from tank fill % instead + OxygenTriggerProportion = 0.5f, + MuteIfOxygenSourcesNearby = true, }, - new Alert(this, "{player}'s oxygen is about to run out.") { - minDepth = 15, // base game's is 0 + new Alert { + Message = "{player}'s oxygen is about to run out.", + // minDepth = 15, // base game's is 0 minO2Capacity = 30, notification = null, // TODO: play the alarm sound but not the PDA voiceline (create PDANotification that plays only one of the two sounds) oxygenTriggerSeconds = 10, + MuteIfOxygenSourcesNearby = false, + Cooldown = 30, } ]; + _ourAlerts[0].OnPlay = RerollOxygenProportion; + } + + private static void RerollOxygenProportion(Alert alert) + { + // decay by 5% of current value each reroll down to a minimum of 0.25 fill (25% of capacity) + alert.OxygenTriggerProportion = Random.Range(Mathf.Max(0.25f, alert.OxygenTriggerProportion * 0.95f), alert.OxygenTriggerProportion); + LOGGER.LogDebug($"New oxy threshold ({alert.OxygenTriggerProportion*100:f2}%)"); } private void OnEnable() @@ -45,9 +61,11 @@ private void OnEnable() private IEnumerator InitCoro() { yield return new WaitUntil(() => Player.main); - _alerts = Player.main.GetComponentInChildren(); + _player = Player.main; + if (!_alerts) + _alerts = Player.main.GetComponentInChildren(); _gameAlerts = _alerts.alertList; - _alerts.alertList = _ourAlerts; + _alerts.alertList = []; } private void OnDisable() @@ -57,15 +75,115 @@ private void OnDisable() _alerts.alertList = _gameAlerts; } - private void Notify(Alert alert) + private readonly Utils.ScalarMonitor _secondsMonitor = new(100f); + private readonly Utils.ScalarMonitor _proportionMonitor = new(1f); + private int _lastAlert = -1; + + private void Update() + { + if (!_player || !_player.IsAlive()) return; + if (_player.CanBreathe()) return; + if (!GameModeManager.GetOption(GameOption.OxygenDepletes)) return; + + float time = Time.time; + + // prevent 30s alert from triggering right after the 10s one + float lastAlertCooldownTimer = _lastAlert >= 0 && _lastAlert < _ourAlerts.Count + ? _ourAlerts[_lastAlert].CooldownTimer + : 0; + bool lastAlertOnCooldown = lastAlertCooldownTimer > time; + + float capacity = _player.GetOxygenCapacity(); + float has = _player.GetOxygenAvailable(); + float drain = _player.GetOxygenPerBreath(_player.GetBreathPeriod(), _player.depthClass.value); + float proportion = has / capacity; + _proportionMonitor.Update(proportion); + + float emptyInSeconds = Mathf.Max(0, has / drain); + _secondsMonitor.Update(emptyInSeconds); + for (int i = _ourAlerts.Count - 1; i >= 0; i--) + { + Alert alert = _ourAlerts[i]; + if (alert.CooldownTimer > time) continue; + if (_lastAlert > i && lastAlertOnCooldown) continue; + + if (alert.minO2Capacity > capacity) continue; + bool isProportionTrigger = alert.oxygenTriggerSeconds <= 0; + if (isProportionTrigger) + { + if (alert.OxygenTriggerProportion < proportion) continue; + // if (!_proportionMonitor.JustDroppedBelow(alert.OxygenTriggerProportion)) break; + } + else + { + if (alert.oxygenTriggerSeconds < emptyInSeconds) continue; + // if (!_secondsMonitor.JustDroppedBelow(alert.oxygenTriggerSeconds)) break; + } + + float depth = _player.GetDepth(); + if (alert.minDepth > depth) continue; + + if (alert.MuteIfOxygenSourcesNearby) + { + if (alert.NextCheck > time) continue; + alert.NextCheck = time + 2f; + + float swimDistance = emptyInSeconds * 5; // 5 m/s is very generous, base swim speed is 6.64 and seaglide is 25 + + if (swimDistance > depth) + { + LOGGER.LogDebug($"Close to surface {swimDistance} > {depth}"); + + continue; + } + + // whoever is reading this in probably like 2040 or something - do you think anyone ever noticed this doesn't actually get the nearest one + IInteriorSpace interior = _player.GetRespawnInterior(); + bool interiorIsBreathable = interior switch + { + SubRoot sub => sub.powerRelay && sub.powerRelay.IsPowered(), + Vehicle vehicle => vehicle.IsPowered(), + SeaTruckSegment truck => truck.relay && truck.relay.IsPowered(), + _ => false, + }; + if (interiorIsBreathable) + { + float dist = (interior.GetGameObject().transform.position - _player.transform.position).magnitude; + if (swimDistance > dist) + { + LOGGER.LogDebug($"Base close by {swimDistance} > {dist}"); + continue; + } + } + } + + _lastAlert = i; + Play(alert); + break; + } + } + + internal void Play(Alert alert) { + if (alert.notification) alert.notification.Play(); + alert.CooldownTimer = Time.time + alert.Cooldown; + alert.OnPlay?.Invoke(alert); + React(alert.Priority, Format.FormatPlayer(alert.Message)); } - internal sealed class Alert(OxygenAlerts owner, string message, Priority priority = Priority.High) - : LowOxygenAlert.Alert + internal sealed class Alert : LowOxygenAlert.Alert { - public string Message { get; } = message; - public Priority Priority { get; } = priority; - public void Play() => owner.Notify(this); + public required string Message { get; set; } + public Priority Priority { get; set; } = Priority.High; + public float OxygenTriggerProportion { get; set; } = 1; + /// + /// Includes the surface and nearby breathable interiors (base, vehicle, etc). + /// + public bool MuteIfOxygenSourcesNearby { get; set; } + public float NextCheck { get; set; } + public float Cooldown { get; set; } = 60; + public float CooldownTimer { get; set; } + + public Action OnPlay { get; set; } } }