/ GUNRPG.Core / Combat / SuppressionModel.cs
SuppressionModel.cs
  1  namespace GUNRPG.Core.Combat;
  2  
  3  /// <summary>
  4  /// Models suppression mechanics - the psychological pressure from near-misses and incoming fire
  5  /// that affects operator performance without dealing damage. Suppression is triggered by
  6  /// threatening shots that miss, applies temporary penalties that decay over time, and
  7  /// complements flinch (hit-based) without replacing it.
  8  /// </summary>
  9  public static class SuppressionModel
 10  {
 11      /// <summary>
 12      /// Maximum suppression level (capped to prevent stun-lock).
 13      /// </summary>
 14      public const float MaxSuppressionLevel = 1.0f;
 15  
 16      /// <summary>
 17      /// Minimum suppression level (below this, operator is not considered suppressed).
 18      /// </summary>
 19      public const float MinSuppressionLevel = 0.0f;
 20  
 21      /// <summary>
 22      /// Suppression threshold - level at which an operator is considered "suppressed".
 23      /// </summary>
 24      public const float SuppressionThreshold = 0.1f;
 25  
 26      /// <summary>
 27      /// Base suppression amount applied per near-miss.
 28      /// Scaled by weapon class, fire rate, and distance.
 29      /// </summary>
 30      public const float BaseSuppressionPerMiss = 0.15f;
 31  
 32      /// <summary>
 33      /// Angular deviation threshold (degrees) within which a miss still causes suppression.
 34      /// Shots that deviate more than this from the target are not threatening enough to suppress.
 35      /// </summary>
 36      public const float SuppressionAngleThresholdDegrees = 0.5f;
 37  
 38      /// <summary>
 39      /// Exponential decay rate per second when not under fire.
 40      /// Higher values = faster decay.
 41      /// </summary>
 42      public const float DecayRatePerSecond = 0.8f;
 43  
 44      /// <summary>
 45      /// Decay rate multiplier when under continued fire (slowed decay).
 46      /// </summary>
 47      public const float DecayRateUnderFireMultiplier = 0.25f;
 48  
 49      /// <summary>
 50      /// Time window (ms) to consider "under continued fire" for decay slowdown.
 51      /// </summary>
 52      public const long ContinuedFireWindowMs = 500;
 53  
 54      // Effect constants - penalties scale linearly with suppression level
 55  
 56      /// <summary>
 57      /// Maximum ADS time penalty at full suppression (multiplier).
 58      /// At max suppression, ADS time is increased by this factor.
 59      /// </summary>
 60      public const float MaxADSTimePenaltyMultiplier = 0.5f;
 61  
 62      /// <summary>
 63      /// Maximum accuracy proficiency reduction at full suppression.
 64      /// Reduces effective accuracy proficiency by this percentage.
 65      /// </summary>
 66      public const float MaxAccuracyProficiencyReduction = 0.4f;
 67  
 68      /// <summary>
 69      /// Maximum recoil control penalty at full suppression.
 70      /// Reduces effective recoil control by this percentage.
 71      /// </summary>
 72      public const float MaxRecoilControlPenalty = 0.3f;
 73  
 74      /// <summary>
 75      /// Maximum reaction delay at full suppression (ms).
 76      /// </summary>
 77      public const float MaxReactionDelayMs = 50f;
 78  
 79      /// <summary>
 80      /// Minimum proficiency floor when suppressed (prevents total ineffectiveness).
 81      /// </summary>
 82      public const float SuppressionProficiencyFloorFactor = 0.4f;
 83  
 84      /// <summary>
 85      /// Calculates suppression severity based on weapon characteristics and distance.
 86      /// </summary>
 87      /// <param name="weaponSuppressionFactor">Weapon-specific suppression factor (LMGs higher than SMGs)</param>
 88      /// <param name="weaponFireRateRPM">Weapon fire rate in rounds per minute</param>
 89      /// <param name="distanceMeters">Distance between shooter and target</param>
 90      /// <param name="angularDeviationDegrees">How close the shot came to hitting (degrees)</param>
 91      /// <param name="targetMovementState">Optional movement state of the target to apply modifiers</param>
 92      /// <param name="targetDirection">Optional movement direction of the target to apply directional modifiers</param>
 93      /// <returns>Suppression severity to apply (0.0 - 1.0)</returns>
 94      public static float CalculateSuppressionSeverity(
 95          float weaponSuppressionFactor,
 96          float weaponFireRateRPM,
 97          float distanceMeters,
 98          float angularDeviationDegrees,
 99          Operators.MovementState? targetMovementState = null,
100          Operators.MovementDirection? targetDirection = null)
101      {
102          // No suppression if shot was too far off
103          if (Math.Abs(angularDeviationDegrees) > SuppressionAngleThresholdDegrees)
104              return 0f;
105  
106          // Base suppression from weapon class
107          float baseSuppression = BaseSuppressionPerMiss * weaponSuppressionFactor;
108  
109          // Fire rate factor: higher fire rate = more sustained pressure
110          // Normalize around 600 RPM as baseline
111          float fireRateFactor = Math.Clamp(weaponFireRateRPM / 600f, 0.5f, 2.0f);
112  
113          // Distance factor: closer = more suppressive
114          // Peak suppression at close range, falls off with distance
115          float distanceFactor = CalculateDistanceFactor(distanceMeters);
116  
117          // Angular closeness factor: closer misses are more suppressive
118          float closenessFactor = 1.0f - (Math.Abs(angularDeviationDegrees) / SuppressionAngleThresholdDegrees);
119  
120          float severity = baseSuppression * fireRateFactor * distanceFactor * closenessFactor;
121  
122          // Apply movement modifier for target
123          if (targetMovementState.HasValue)
124          {
125              float movementMultiplier = MovementModel.GetSuppressionBuildupMultiplier(targetMovementState.Value);
126              severity *= movementMultiplier;
127          }
128  
129          // Apply directional modifier for target (advancing = more exposed, retreating = less exposed)
130          if (targetDirection.HasValue)
131          {
132              float directionMultiplier = MovementModel.GetDirectionalSuppressionMultiplier(targetDirection.Value);
133              severity *= directionMultiplier;
134          }
135  
136          return Math.Clamp(severity, 0f, MaxSuppressionLevel);
137      }
138  
139      /// <summary>
140      /// Calculates distance factor for suppression.
141      /// Closer targets receive more suppression.
142      /// </summary>
143      private static float CalculateDistanceFactor(float distanceMeters)
144      {
145          // Full suppression up to 10m, then linear falloff to 50% at 50m, then 25% beyond
146          if (distanceMeters <= 10f)
147              return 1.0f;
148          if (distanceMeters <= 50f)
149              return 1.0f - (distanceMeters - 10f) / 80f; // 0.5 at 50m
150          return 0.25f; // Minimum floor at long range
151      }
152  
153      /// <summary>
154      /// Applies suppression decay over time.
155      /// </summary>
156      /// <param name="currentSuppression">Current suppression level</param>
157      /// <param name="deltaMs">Time elapsed since last update</param>
158      /// <param name="isUnderFire">Whether the operator is currently under continued fire</param>
159      /// <param name="movementState">Optional movement state to apply decay modifiers</param>
160      /// <param name="responseProficiency">Optional response proficiency scaling the decay rate (0.0-1.0): lower values slow recovery, higher values speed it up.</param>
161      /// <returns>New suppression level after decay</returns>
162      public static float ApplyDecay(
163          float currentSuppression, 
164          long deltaMs, 
165          bool isUnderFire, 
166          Operators.MovementState? movementState = null,
167          float? responseProficiency = null)
168      {
169          if (currentSuppression <= MinSuppressionLevel)
170              return MinSuppressionLevel;
171  
172          float deltaSeconds = deltaMs / 1000f;
173          float effectiveDecayRate = isUnderFire 
174              ? DecayRatePerSecond * DecayRateUnderFireMultiplier 
175              : DecayRatePerSecond;
176  
177          // Apply movement modifier for decay rate
178          if (movementState.HasValue)
179          {
180              float movementMultiplier = MovementModel.GetSuppressionDecayMultiplier(movementState.Value);
181              effectiveDecayRate *= movementMultiplier;
182          }
183  
184          // Apply response proficiency modifier for faster recovery
185          if (responseProficiency.HasValue)
186          {
187              effectiveDecayRate = ResponseProficiencyModel.CalculateEffectiveSuppressionDecayRate(
188                  effectiveDecayRate, responseProficiency.Value);
189          }
190  
191          // Exponential decay: S(t) = S0 * e^(-rate * t)
192          float decayFactor = (float)Math.Exp(-effectiveDecayRate * deltaSeconds);
193          float newSuppression = currentSuppression * decayFactor;
194  
195          // Snap to zero if below threshold
196          if (newSuppression < SuppressionThreshold * 0.1f)
197              return MinSuppressionLevel;
198  
199          return Math.Clamp(newSuppression, MinSuppressionLevel, MaxSuppressionLevel);
200      }
201  
202      /// <summary>
203      /// Calculates ADS time penalty due to suppression.
204      /// </summary>
205      /// <param name="baseADSTimeMs">Base ADS time in milliseconds</param>
206      /// <param name="suppressionLevel">Current suppression level (0.0 - 1.0)</param>
207      /// <returns>Effective ADS time including penalty</returns>
208      public static float CalculateEffectiveADSTime(float baseADSTimeMs, float suppressionLevel)
209      {
210          suppressionLevel = Math.Clamp(suppressionLevel, MinSuppressionLevel, MaxSuppressionLevel);
211          float penalty = suppressionLevel * MaxADSTimePenaltyMultiplier;
212          return baseADSTimeMs * (1f + penalty);
213      }
214  
215      /// <summary>
216      /// Calculates effective accuracy proficiency under suppression.
217      /// </summary>
218      /// <param name="baseAccuracyProficiency">Base accuracy proficiency (0.0 - 1.0)</param>
219      /// <param name="suppressionLevel">Current suppression level (0.0 - 1.0)</param>
220      /// <returns>Effective accuracy proficiency after suppression penalty</returns>
221      public static float CalculateEffectiveAccuracyProficiency(
222          float baseAccuracyProficiency,
223          float suppressionLevel)
224      {
225          baseAccuracyProficiency = Math.Clamp(baseAccuracyProficiency, 0f, 1f);
226          suppressionLevel = Math.Clamp(suppressionLevel, MinSuppressionLevel, MaxSuppressionLevel);
227  
228          // Calculate reduction factor based on suppression
229          float reductionFactor = 1f - (suppressionLevel * MaxAccuracyProficiencyReduction);
230  
231          // Apply floor to prevent total ineffectiveness
232          float minProficiency = baseAccuracyProficiency * SuppressionProficiencyFloorFactor;
233          float effectiveProficiency = baseAccuracyProficiency * reductionFactor;
234  
235          return Math.Max(effectiveProficiency, minProficiency);
236      }
237  
238      /// <summary>
239      /// Calculates effective recoil control factor under suppression.
240      /// </summary>
241      /// <param name="baseRecoilControlFactor">Base recoil control factor</param>
242      /// <param name="suppressionLevel">Current suppression level (0.0 - 1.0)</param>
243      /// <returns>Effective recoil control factor after suppression penalty</returns>
244      public static float CalculateEffectiveRecoilControlFactor(
245          float baseRecoilControlFactor,
246          float suppressionLevel)
247      {
248          suppressionLevel = Math.Clamp(suppressionLevel, MinSuppressionLevel, MaxSuppressionLevel);
249          float penalty = suppressionLevel * MaxRecoilControlPenalty;
250          return baseRecoilControlFactor * (1f - penalty);
251      }
252  
253      /// <summary>
254      /// Calculates reaction delay due to suppression.
255      /// </summary>
256      /// <param name="suppressionLevel">Current suppression level (0.0 - 1.0)</param>
257      /// <returns>Reaction delay in milliseconds</returns>
258      public static float CalculateReactionDelay(float suppressionLevel)
259      {
260          suppressionLevel = Math.Clamp(suppressionLevel, MinSuppressionLevel, MaxSuppressionLevel);
261          return suppressionLevel * MaxReactionDelayMs;
262      }
263  
264      /// <summary>
265      /// Determines if suppression should be applied based on shot deviation.
266      /// </summary>
267      /// <param name="angularDeviationDegrees">Deviation from target in degrees</param>
268      /// <returns>True if the shot was close enough to cause suppression</returns>
269      public static bool ShouldApplySuppression(float angularDeviationDegrees)
270      {
271          return Math.Abs(angularDeviationDegrees) <= SuppressionAngleThresholdDegrees;
272      }
273  
274      /// <summary>
275      /// Combines existing suppression with new suppression (stacks up to max).
276      /// </summary>
277      /// <param name="currentSuppression">Current suppression level</param>
278      /// <param name="additionalSuppression">New suppression to add</param>
279      /// <returns>Combined suppression level (capped at max)</returns>
280      public static float CombineSuppression(float currentSuppression, float additionalSuppression)
281      {
282          return Math.Clamp(
283              currentSuppression + additionalSuppression,
284              MinSuppressionLevel,
285              MaxSuppressionLevel);
286      }
287  }