All files / systems/bodypart InjuryTracker.ts

100% Statements 58/58
96.15% Branches 25/26
100% Functions 12/12
100% Lines 57/57

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410                                                                                                                                                                        23x                                                                                   85x 85x 85x                                                         94x 1x       93x         93x       94x   27x 27x   27x             27x 27x     66x 66x                     66x     66x 5x     66x                                         96x 96x   96x 83x 66x 66x 28x 28x         96x                         33x                           6x                                               8x   8x 2x 6x 2x   4x                             6x                 5x 5x   5x 30x 5x 5x       5x 5x                       3x 3x                     3x 3x   3x 3x 1x       3x 1x                       3x                                                         23x  
/**
 * Injury Tracking System
 * 
 * **Korean**: 부상 추적 시스템
 * 
 * Tracks individual injuries on character models for realistic trauma visualization.
 * Records injury location, type, severity, and timestamp for progressive bruising,
 * cuts, and bleeding effects during combat.
 * 
 * ## Features
 * 
 * - Track injuries by body part and 3D position
 * - Progressive bruising: Multiple hits to same location darken existing bruises
 * - Color-coded severity (getBruiseColor): Yellow (fresh), Purple (moderate), Dark red (severe)
 * - Note: TraumaOverlay3D uses different progression: Dark red → Indigo → Black
 * - Blood effects triggered when damage > 30 in single hit
 * - Injury persistence across combat rounds
 * - Nearby injury lookup using linear scan over tracked injuries (O(n))
 * 
 * @module systems/bodypart/InjuryTracker
 * @category Body Part System
 * @korean 부상추적시스템
 */
 
import * as THREE from "three";
import { BodyPart } from "./types";
import { InjuryType } from "../../types/injury";
import { BodyRegion } from "../../types/common";
 
/**
 * Individual injury location data.
 * 
 * **Korean**: 부상 위치 데이터
 * 
 * Records a single injury with its location, severity, and cumulative hit count.
 * Used for progressive bruising visualization.
 * 
 * @public
 * @category Injury Tracking
 * @korean 부상위치
 */
export interface InjuryLocation {
  /** Unique identifier */
  readonly id: string;
  /** Body part affected */
  readonly bodyPart: BodyPart;
  /** Body region for hit detection */
  readonly bodyRegion: BodyRegion;
  /** 3D position relative to character center */
  readonly position: THREE.Vector3;
  /** Damage severity (0-100) */
  readonly severity: number;
  /** Number of hits to this location (for progressive bruising) */
  readonly hitCount: number;
  /** Timestamp when injury occurred */
  readonly timestamp: number;
  /** Injury type */
  readonly type: InjuryType;
}
 
/**
 * Configuration for injury tracking behavior.
 * 
 * @public
 * @category Injury Tracking
 */
export interface InjuryTrackerConfig {
  /** Maximum number of injuries to track per character */
  readonly maxInjuries: number;
  /** Distance threshold for considering injuries at same location (in units) */
  readonly sameLocationThreshold: number;
  /** Minimum damage required to create an injury */
  readonly minDamageForInjury: number;
  /** Damage threshold for blood effects */
  readonly bloodEffectThreshold: number;
  /** Time before injuries are removed/expired (milliseconds) */
  readonly injuryExpirationTimeMs: number;
}
 
/**
 * Default injury tracker configuration.
 * 
 * @public
 */
export const DEFAULT_INJURY_TRACKER_CONFIG: InjuryTrackerConfig = {
  maxInjuries: 50, // Reasonable limit for performance
  sameLocationThreshold: 0.6, // 0.6 units distance (accommodates ±0.15 randomization)
  minDamageForInjury: 5, // Minimum 5 damage to show injury
  bloodEffectThreshold: 30, // Blood effects when damage > 30
  injuryExpirationTimeMs: 30000, // Injuries are removed after 30 seconds
} as const;
 
/**
 * Injury Tracker System.
 * 
 * **Korean**: 부상 추적 시스템
 * 
 * Manages injury recording and retrieval for trauma visualization.
 * Implements progressive bruising by tracking hit counts at similar locations.
 * 
 * @example
 * ```typescript
 * const tracker = new InjuryTracker();
 * 
 * // Record injury from hit
 * const injury = tracker.recordInjury(
 *   BodyPart.TORSO_UPPER,
 *   BodyRegion.TORSO,
 *   new THREE.Vector3(0, 1.5, 0),
 *   25,
 *   InjuryType.BRUISE
 * );
 * 
 * // Get all injuries for visualization
 * const injuries = tracker.getInjuries();
 * ```
 * 
 * @public
 * @category Body Part System
 */
export class InjuryTracker {
  private injuries: Map<string, InjuryLocation>;
  private config: InjuryTrackerConfig;
  private nextId: number;
 
  constructor(config: InjuryTrackerConfig = DEFAULT_INJURY_TRACKER_CONFIG) {
    this.injuries = new Map();
    this.config = config;
    this.nextId = 0;
  }
 
  /**
   * Record a new injury or update existing one at similar location.
   * 
   * **Korean**: 부상 기록
   * 
   * If an injury exists near the hit position, it updates the existing injury
   * with increased severity and hit count (progressive bruising). Otherwise,
   * creates a new injury.
   * 
   * @param bodyPart - Body part affected
   * @param bodyRegion - Body region for damage distribution
   * @param position - 3D position relative to character center
   * @param damage - Damage amount (0-100)
   * @param type - Type of injury
   * @returns The created or updated injury, or null if damage is below threshold
   * 
   * @public
   */
  recordInjury(
    bodyPart: BodyPart,
    bodyRegion: BodyRegion,
    position: THREE.Vector3,
    damage: number,
    type: InjuryType
  ): InjuryLocation | null {
    // Check if damage is significant enough to track
    if (damage < this.config.minDamageForInjury) {
      return null;
    }
 
    // Find nearby injury at similar location (same body part)
    const existing = this.findNearbyInjury(bodyPart, position, this.config.sameLocationThreshold);
 
    // Only merge injuries that also match type and body region to avoid
    // incorrectly combining different injury types at the same spot
    const shouldMerge =
      !!existing &&
      existing.type === type &&
      existing.bodyRegion === bodyRegion;
 
    if (shouldMerge && existing) {
      // Progressive bruising - update existing injury
      const newSeverity = Math.min(100, existing.severity + damage / 2);
      const newHitCount = existing.hitCount + 1;
 
      const updated: InjuryLocation = {
        ...existing,
        severity: newSeverity,
        hitCount: newHitCount,
        timestamp: Date.now(),
      };
 
      this.injuries.set(existing.id, updated);
      return updated;
    } else {
      // Create new injury
      const id = `injury-${this.nextId++}`;
      const injury: InjuryLocation = {
        id,
        bodyPart,
        bodyRegion,
        position: position.clone(),
        severity: damage,
        hitCount: 1,
        timestamp: Date.now(),
        type,
      };
 
      this.injuries.set(id, injury);
 
      // Enforce max injuries limit
      if (this.injuries.size > this.config.maxInjuries) {
        this.removeOldestInjury();
      }
 
      return injury;
    }
  }
 
  /**
   * Find injury near a given position on the same body part.
   * 
   * **Korean**: 인근 부상 찾기
   * 
   * @param bodyPart - Body part to search
   * @param position - Position to search near
   * @param threshold - Distance threshold
   * @returns Nearby injury or null
   * 
   * @public
   */
  findNearbyInjury(
    bodyPart: BodyPart,
    position: THREE.Vector3,
    threshold: number
  ): InjuryLocation | null {
    let closestInjury: InjuryLocation | null = null;
    let closestDistance = threshold;
 
    for (const injury of this.injuries.values()) {
      if (injury.bodyPart === bodyPart) {
        const distance = injury.position.distanceTo(position);
        if (distance < closestDistance) {
          closestDistance = distance;
          closestInjury = injury;
        }
      }
    }
 
    return closestInjury;
  }
 
  /**
   * Get all tracked injuries.
   * 
   * **Korean**: 모든 부상 조회
   * 
   * @returns Array of all injuries
   * 
   * @public
   */
  getInjuries(): InjuryLocation[] {
    return Array.from(this.injuries.values());
  }
 
  /**
   * Get injuries for a specific body part.
   * 
   * **Korean**: 신체 부위 부상 조회
   * 
   * @param bodyPart - Body part to query
   * @returns Array of injuries on that body part
   * 
   * @public
   */
  getInjuriesByBodyPart(bodyPart: BodyPart): InjuryLocation[] {
    return this.getInjuries().filter((injury) => injury.bodyPart === bodyPart);
  }
 
  /**
   * Get bruise color based on severity and hit count.
   * 
   * **Korean**: 타박상 색상 가져오기
   * 
   * Progressive color scheme (for reference - TraumaOverlay3D uses its own colors):
   * - Light bruising (severity < 20): Yellow (#ffeb3b)
   * - Moderate bruising (severity < 50): Purple (#9c27b0)
   * - Severe bruising (severity >= 50): Dark red (#b71c1c)
   * 
   * Note: TraumaOverlay3D uses Dark red → Indigo → Black progression.
   * Consider using TraumaOverlay3D's getBruiseColor for consistent visualization.
   * 
   * @param severity - Injury severity (0-100)
   * @param hitCount - Number of hits to same location
   * @returns Hex color string
   * 
   * @public
   */
  getBruiseColor(severity: number, hitCount: number): string {
    // Progressive darkening with hit count (subtract 1 since first hit shouldn't darken)
    const effectiveSeverity = severity + Math.max(0, hitCount - 1) * 10;
 
    if (effectiveSeverity < 20) {
      return "#ffeb3b"; // Yellow - light bruising
    } else if (effectiveSeverity < 50) {
      return "#9c27b0"; // Purple - moderate bruising
    } else {
      return "#b71c1c"; // Dark red - severe bruising
    }
  }
 
  /**
   * Check if damage should trigger blood effects.
   * 
   * **Korean**: 출혈 효과 필요 여부 확인
   * 
   * @param damage - Damage amount
   * @returns Whether to show blood effects
   * 
   * @public
   */
  shouldShowBloodEffect(damage: number): boolean {
    return damage > this.config.bloodEffectThreshold;
  }
 
  /**
   * Remove oldest injury to maintain performance.
   * 
   * @private
   */
  private removeOldestInjury(): void {
    let oldestId: string | null = null;
    let oldestTimestamp = Infinity;
 
    for (const [id, injury] of this.injuries.entries()) {
      if (injury.timestamp < oldestTimestamp) {
        oldestTimestamp = injury.timestamp;
        oldestId = id;
      }
    }
 
    Eif (oldestId) {
      this.injuries.delete(oldestId);
    }
  }
 
  /**
   * Clear all injuries (for new match/round).
   * 
   * **Korean**: 모든 부상 초기화
   * 
   * @public
   */
  clearInjuries(): void {
    this.injuries.clear();
    this.nextId = 0;
  }
 
  /**
   * Remove injuries older than expiration time.
   * 
   * **Korean**: 만료된 부상 제거
   * 
   * @public
   */
  removeExpiredInjuries(): void {
    const now = Date.now();
    const expiredIds: string[] = [];
 
    for (const [id, injury] of this.injuries.entries()) {
      if (now - injury.timestamp > this.config.injuryExpirationTimeMs) {
        expiredIds.push(id);
      }
    }
 
    for (const id of expiredIds) {
      this.injuries.delete(id);
    }
  }
 
  /**
   * Get injury count for performance monitoring.
   * 
   * @returns Current number of tracked injuries
   * 
   * @public
   */
  getInjuryCount(): number {
    return this.injuries.size;
  }
}
 
/**
 * Singleton instance of Injury Tracker.
 * 
 * **Korean**: 부상 추적 시스템 싱글톤
 * 
 * **Warning**: This singleton does **not** track any playerId/character identifier
 * on injuries. All recorded injuries are stored together in a single collection.
 * 
 * For any scenario with more than one character (including 1v1 combat), you
 * **must** create a separate {@link InjuryTracker} instance per character to avoid
 * mixing injuries between characters:
 * 
 * ```typescript
 * const player1Tracker = new InjuryTracker();
 * const player2Tracker = new InjuryTracker();
 * ```
 * 
 * This singleton is intended only for simple, single-character use cases
 * (e.g., local visualization tools, single dummy target, or non-combat demos)
 * where all injuries belong to one entity.
 * 
 * @public
 * @deprecated Use per-character {@link InjuryTracker} instances instead to avoid
 * mixing injuries from multiple characters.
 */
export const injuryTracker = new InjuryTracker();