/ GUNRPG.Core / Combat / HitResolution.cs
HitResolution.cs
  1  using GUNRPG.Core.Operators;
  2  using GUNRPG.Core.Weapons;
  3  
  4  namespace GUNRPG.Core.Combat;
  5  
  6  /// <summary>
  7  /// Represents the result of a shot resolution.
  8  /// </summary>
  9  public sealed record ShotResolutionResult(BodyPart HitLocation, float FinalAngleDegrees);
 10  
 11  /// <summary>
 12  /// Captures intermediate values used when resolving a shot.
 13  /// </summary>
 14  public sealed class ShotResolutionDetails
 15  {
 16      public float BaseAimAngle { get; set; }
 17      public float AimError { get; set; }
 18      public float SwayNoise { get; set; }
 19      public float RecoilAdded { get; set; }
 20      public float FinalAimAngle { get; set; }
 21      
 22      /// <summary>
 23      /// Total aim deviation including both aim error and sway noise.
 24      /// </summary>
 25      public float TotalAimDeviation => AimError + SwayNoise;
 26  }
 27  
 28  /// <summary>
 29  /// Implements vertical body-part hit resolution based on angular bands.
 30  /// Assumptions:
 31  /// - NO horizontal recoil
 32  /// - Each round resolves at most ONE shot per operator
 33  /// - A shot either hits a body part or misses entirely
 34  /// - Recoil is modeled as vertical angular displacement only
 35  /// - Distance affects which body part is intersected based on vertical angle
 36  /// </summary>
 37  public static class HitResolution
 38  {
 39      /// <summary>
 40      /// Angular range definition for a body part.
 41      /// </summary>
 42      private sealed record AngularBand(float MinDegrees, float MaxDegrees, BodyPart BodyPart);
 43  
 44      /// <summary>
 45      /// Body part angular bands (in degrees).
 46      /// - LowerTorso: 0.00° – 0.25°
 47      /// - UpperTorso: 0.25° – 0.50°
 48      /// - Neck:       0.50° – 0.75°
 49      /// - Head:       0.75° – 1.00°
 50      /// </summary>
 51      private static readonly AngularBand[] AngularBands = new[]
 52      {
 53          new AngularBand(0.00f, 0.25f, BodyPart.LowerTorso),
 54          new AngularBand(0.25f, 0.50f, BodyPart.UpperTorso),
 55          new AngularBand(0.50f, 0.75f, BodyPart.Neck),
 56          new AngularBand(0.75f, 1.00f, BodyPart.Head)
 57      };
 58  
 59      private const float MinAngle = 0.00f;
 60      private const float MaxAngle = 1.00f;
 61  
 62      /// <summary>
 63      /// Resolves a shot to determine which body part (if any) is hit.
 64      /// This overload includes AccuracyProficiency support for operator-driven recoil control.
 65      /// Uses AccuracyModel for proficiency calculations to ensure consistency.
 66      /// </summary>
 67      /// <param name="targetBodyPart">Intended target body part</param>
 68      /// <param name="operatorAccuracy">Operator accuracy stat (affects standard deviation of aim error)</param>
 69      /// <param name="accuracyProficiency">Operator proficiency stat (0.0-1.0) that affects recoil counteraction</param>
 70      /// <param name="weaponVerticalRecoil">Weapon's vertical recoil value (unchanged)</param>
 71      /// <param name="currentRecoilY">Current accumulated vertical recoil state</param>
 72      /// <param name="recoilVariance">Optional variance in recoil application</param>
 73      /// <param name="random">Random number generator for deterministic-friendly operation</param>
 74      /// <param name="details">Optional details object to capture intermediate values</param>
 75      /// <param name="movementState">Optional movement state to apply movement modifiers</param>
 76      /// <param name="targetDirection">Optional movement direction of target to apply directional hit probability modifiers</param>
 77      /// <returns>The resolved body part hit or Miss if shot overshoots/undershoots</returns>
 78      public static ShotResolutionResult ResolveShotWithProficiency(
 79          BodyPart targetBodyPart,
 80          float operatorAccuracy,
 81          float accuracyProficiency,
 82          float weaponVerticalRecoil,
 83          float currentRecoilY,
 84          float recoilVariance,
 85          Random random,
 86          ShotResolutionDetails? details = null,
 87          MovementState? movementState = null,
 88          MovementDirection? targetDirection = null)
 89      {
 90          // Clamp proficiency to valid range
 91          accuracyProficiency = Math.Clamp(accuracyProficiency, 0f, 1f);
 92          
 93          // Apply movement accuracy modifier
 94          float effectiveAccuracy = operatorAccuracy;
 95          float weaponSway = 0f;
 96          if (movementState.HasValue)
 97          {
 98              float accuracyMultiplier = MovementModel.GetAccuracyMultiplier(movementState.Value);
 99              effectiveAccuracy = operatorAccuracy * accuracyMultiplier;
100              weaponSway = MovementModel.GetWeaponSwayDegrees(movementState.Value);
101          }
102          
103          // Get the center angle of the target body part
104          float targetAngle = GetBodyPartCenterAngle(targetBodyPart);
105  
106          // Apply initial aim acquisition error using AccuracyModel
107          float aimErrorStdDev = AccuracyModel.CalculateAimErrorStdDev(effectiveAccuracy, accuracyProficiency);
108          
109          // Apply directional hit probability modifier to target
110          // Advancing = easier to hit (smaller error), Retreating = harder to hit (larger error)
111          if (targetDirection.HasValue)
112          {
113              float directionMultiplier = MovementModel.GetDirectionalHitProbabilityMultiplier(targetDirection.Value);
114              // Invert the multiplier for aim error: higher hit probability = lower error
115              // e.g., 1.15x hit probability -> 1/1.15 = 0.87x error
116              // e.g., 0.9x hit probability -> 1/0.9 = 1.11x error
117              aimErrorStdDev /= directionMultiplier;
118          }
119          
120          float aimError = AccuracyModel.SampleGaussian(random, 0f, aimErrorStdDev);
121  
122          // Apply weapon sway as additional angular noise
123          float swayNoise = weaponSway > 0f 
124              ? AccuracyModel.SampleGaussian(random, 0f, weaponSway)
125              : 0f;
126  
127          // Apply recoil counteraction using AccuracyModel
128          float recoilReductionFactor = AccuracyModel.CalculateRecoilReductionFactor(accuracyProficiency);
129          float effectiveWeaponRecoil = weaponVerticalRecoil * recoilReductionFactor;
130  
131          // Apply variance reduction using AccuracyModel
132          float effectiveVariance = AccuracyModel.CalculateEffectiveVariance(recoilVariance, accuracyProficiency);
133          float recoilVariation = effectiveVariance > 0
134              ? (float)(random.NextDouble() * 2.0 - 1.0) * effectiveVariance
135              : 0f;
136  
137          // Apply proficiency-based counteraction to accumulated recoil as well
138          float effectiveCurrentRecoil = currentRecoilY * recoilReductionFactor;
139          
140          float totalVerticalRecoil = effectiveCurrentRecoil + effectiveWeaponRecoil + recoilVariation;
141  
142          // Calculate final vertical angle (including sway)
143          float finalAngle = targetAngle + aimError + swayNoise + totalVerticalRecoil;
144  
145          if (details != null)
146          {
147              details.BaseAimAngle = targetAngle;
148              details.AimError = aimError;
149              details.SwayNoise = swayNoise;
150              details.RecoilAdded = totalVerticalRecoil;
151              details.FinalAimAngle = finalAngle;
152          }
153  
154          // Convert final angle to body part hit
155          BodyPart hitLocation = ConvertAngleToBodyPart(finalAngle);
156  
157          return new ShotResolutionResult(hitLocation, finalAngle);
158      }
159  
160      /// <summary>
161      /// Resolves a shot to determine which body part (if any) is hit.
162      /// </summary>
163      /// <param name="targetBodyPart">Intended target body part</param>
164      /// <param name="operatorAccuracy">Operator accuracy stat (affects standard deviation of aim error)</param>
165      /// <param name="weaponVerticalRecoil">Weapon's vertical recoil value</param>
166      /// <param name="currentRecoilY">Current accumulated vertical recoil state</param>
167      /// <param name="recoilVariance">Optional variance in recoil application</param>
168      /// <param name="random">Random number generator for deterministic-friendly operation</param>
169      /// <returns>The resolved body part hit or Miss if shot overshoots/undershoots</returns>
170      public static ShotResolutionResult ResolveShot(
171          BodyPart targetBodyPart,
172          float operatorAccuracy,
173          float weaponVerticalRecoil,
174          float currentRecoilY,
175          float recoilVariance,
176          Random random)
177      {
178          // Get the center angle of the target body part
179          float targetAngle = GetBodyPartCenterAngle(targetBodyPart);
180  
181          // Apply initial aim acquisition error based on operator accuracy
182          // Lower accuracy = higher standard deviation = more error
183          float aimErrorStdDev = (1.0f - operatorAccuracy) * AccuracyModel.BaseAimErrorScale;
184          float aimError = AccuracyModel.SampleGaussian(random, 0f, aimErrorStdDev);
185  
186          // Apply vertical recoil with variance
187          float recoilVariation = recoilVariance > 0
188              ? (float)(random.NextDouble() * 2.0 - 1.0) * recoilVariance
189              : 0f;
190          float totalVerticalRecoil = currentRecoilY + weaponVerticalRecoil + recoilVariation;
191  
192          // Calculate final vertical angle
193          float finalAngle = targetAngle + aimError + totalVerticalRecoil;
194  
195          // Convert final angle to body part hit
196          BodyPart hitLocation = ConvertAngleToBodyPart(finalAngle);
197  
198          return new ShotResolutionResult(hitLocation, finalAngle);
199      }
200  
201      /// <summary>
202      /// Gets the center angle of a body part's angular band.
203      /// </summary>
204      public static float GetBodyPartCenterAngle(BodyPart bodyPart)
205      {
206          var band = Array.Find(AngularBands, b => b.BodyPart == bodyPart);
207          if (band != null)
208          {
209              return (band.MinDegrees + band.MaxDegrees) / 2f;
210          }
211  
212          // Default to center of lower torso if not found
213          return 0.125f;
214      }
215  
216      /// <summary>
217      /// Converts a final vertical angle into a body part hit using the angular bands.
218      /// Returns Miss if the angle is outside the valid range.
219      /// </summary>
220      private static BodyPart ConvertAngleToBodyPart(float angleDegrees)
221      {
222          // Check for overshoots (above head)
223          if (angleDegrees > MaxAngle)
224              return BodyPart.Miss;
225  
226          // Check for undershoots (below lower torso)
227          if (angleDegrees < MinAngle)
228              return BodyPart.Miss;
229  
230          // Find the matching angular band
231          for (int i = 0; i < AngularBands.Length; i++)
232          {
233              var band = AngularBands[i];
234              // Use inclusive upper bound for the last band (Head) to handle exactly 1.0°
235              bool isLastBand = i == AngularBands.Length - 1;
236              bool inBand = angleDegrees >= band.MinDegrees && 
237                           (isLastBand ? angleDegrees <= band.MaxDegrees : angleDegrees < band.MaxDegrees);
238              
239              if (inBand)
240              {
241                  return band.BodyPart;
242              }
243          }
244  
245          // Should not reach here due to range checks above, but return Miss as fallback
246          return BodyPart.Miss;
247      }
248  }