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 }