All files / components/screens/combat/components/effects BloodDecals3D.tsx

4.21% Statements 4/95
0% Branches 0/30
0% Functions 0/16
4.44% Lines 4/90

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                                                                                                                                        1x                                               1x                                                                                                                                               1x                                                                                                                                                                                                                                           1x                                                                                                                                                                                            
/**
 * BloodDecals3D - Blood accumulation decal system for character models
 *
 * Projects blood decals onto 3D character surfaces using decal geometry for
 * persistent blood visualization. Decals fade over time and track injury
 * persistence across combat rounds.
 *
 * Features:
 * - Decal projection onto character meshes
 * - Blood accumulation from hits and lacerations
 * - Blood trail visualization
 * - Cross-round injury persistence
 * - Korean-themed blood visualization
 *
 * @module components/combat/BloodDecals3D
 * @category Combat Effects
 * @korean 피흔적3D
 */
 
import { DecalGeometry } from "three/examples/jsm/geometries/DecalGeometry.js";
import { useFrame } from "@react-three/fiber";
import React, { useRef, useMemo, useEffect } from "react";
import * as THREE from "three";
import { KOREAN_COLORS } from "../../../../../types/constants";
 
/**
 * Blood decal configuration
 */
export interface BloodDecal {
  /** Unique identifier */
  readonly id: string;
  /** Position in world space */
  readonly position: [number, number, number];
  /** Normal vector for surface projection */
  readonly normal: [number, number, number];
  /** Size of decal */
  readonly size: [number, number, number];
  /** Rotation around normal (radians) */
  readonly rotation: number;
  /** Opacity (0.0 to 1.0) */
  readonly opacity: number;
  /** Creation timestamp */
  readonly timestamp: number;
  /** Whether decal is from a laceration (adds blood trail) */
  readonly isLaceration?: boolean;
}
 
/**
 * Props for BloodDecals3D component
 */
export interface BloodDecals3DProps {
  /** Active blood decals to render */
  readonly decals: readonly BloodDecal[];
  /** Character mesh reference for decal projection */
  readonly targetMeshRef?: React.RefObject<THREE.Mesh>;
  /** Whether decals are enabled (violence settings) */
  readonly enabled?: boolean;
  /** Mobile mode (simplified decals) */
  readonly isMobile?: boolean;
  /** Decal fade duration in seconds */
  readonly fadeDuration?: number;
  /** Callback when decal fully fades */
  readonly onDecalComplete?: (decalId: string) => void;
}
 
/**
 * Blood decal constants
 */
const DECAL_CONSTANTS = {
  /** Default fade duration (seconds) */
  FADE_DURATION: 15.0,
  /** Base decal size */
  BASE_SIZE: [0.15, 0.15, 0.05] as [number, number, number],
  /** Laceration trail size multiplier */
  TRAIL_MULTIPLIER: 3.0,
  /** Maximum concurrent decals for performance */
  MAX_DECALS: 20,
  /** Mobile decal limit */
  MAX_DECALS_MOBILE: 10,
} as const;
 
/**
 * Generate blood decal texture (procedural)
 */
/**
 * Generate a procedural blood texture with irregular edges.
 * Note: Uses Math.random() for visual variety. Each texture will be slightly different,
 * providing natural variation in blood splatter appearance across different decals.
 * This is intentional for realism, though it does create unique textures per component.
 * For better memory efficiency in production, consider pre-generating 3-5 variations
 * and randomly selecting one.
 */
const createBloodTexture = (): THREE.Texture => {
  const canvas = document.createElement("canvas");
  canvas.width = 256;
  canvas.height = 256;
  const ctx = canvas.getContext("2d");
  
  if (!ctx) {
    // Fallback: Return a basic transparent texture with matching dimensions
    console.warn("Blood decal texture generation failed: Could not get 2D context");
    const fallbackCanvas = document.createElement("canvas");
    fallbackCanvas.width = 256;
    fallbackCanvas.height = 256;
    return new THREE.CanvasTexture(fallbackCanvas);
  }
 
  // Background (transparent)
  ctx.clearRect(0, 0, 256, 256);
 
  // Blood splatter pattern
  const centerX = 128;
  const centerY = 128;
 
  // Create gradient from center
  const gradient = ctx.createRadialGradient(
    centerX,
    centerY,
    0,
    centerX,
    centerY,
    100
  );
 
  // Blood color (dark red)
  const bloodColor = new THREE.Color(KOREAN_COLORS.BLOODLOSS_INDICATOR);
  const r = Math.floor(bloodColor.r * 255);
  const g = Math.floor(bloodColor.g * 255);
  const b = Math.floor(bloodColor.b * 255);
 
  gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1.0)`);
  gradient.addColorStop(0.6, `rgba(${r * 0.8}, ${g * 0.6}, ${b * 0.6}, 0.8)`);
  gradient.addColorStop(1, `rgba(${r * 0.5}, ${g * 0.3}, ${b * 0.3}, 0)`);
 
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, 256, 256);
 
  // Add irregular edges for realistic look
  for (let i = 0; i < 30; i++) {
    const angle = (i / 30) * Math.PI * 2;
    const radius = 80 + Math.random() * 20;
    const x = centerX + Math.cos(angle) * radius;
    const y = centerY + Math.sin(angle) * radius;
    const size = 5 + Math.random() * 10;
 
    ctx.fillStyle = `rgba(${r * 0.7}, ${g * 0.4}, ${b * 0.4}, ${0.5 + Math.random() * 0.3})`;
    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fill();
  }
 
  const texture = new THREE.CanvasTexture(canvas);
  texture.needsUpdate = true;
  return texture;
};
 
/**
 * Individual decal component
 */
const DecalMesh: React.FC<{
  decal: BloodDecal;
  texture: THREE.Texture;
  targetMesh?: THREE.Mesh;
  fadeDuration: number;
}> = ({ decal, texture, targetMesh, fadeDuration }) => {
  const meshRef = useRef<THREE.Mesh>(null);
  const materialRef = useRef<THREE.MeshBasicMaterial | null>(null);
  const ageRef = useRef(0);
 
  // Create decal geometry
  useEffect(() => {
    if (!meshRef.current || !targetMesh) return;
 
    try {
      const position = new THREE.Vector3(...decal.position);
      const size = new THREE.Vector3(...decal.size);
 
      // Create decal geometry projected onto target mesh
      // DecalGeometry requires Euler angles for orientation
      const orientation = new THREE.Euler();
      orientation.copy(targetMesh.rotation);
      
      const decalGeometry = new DecalGeometry(
        targetMesh,
        position,
        orientation,
        size
      );
 
      meshRef.current.geometry = decalGeometry;
      
      // Apply rotation to the mesh itself
      meshRef.current.rotation.set(0, 0, decal.rotation);
    } catch (error) {
      // Handle decal projection failures
      // This can occur when target mesh geometry is complex or decal position is invalid
      // Decal will simply not render in this case
      if (process.env.NODE_ENV === "development") {
        // In development, log a diagnostic warning to help debug decal issues
        console.warn("BloodDecals3D: Failed to project blood decal onto target mesh.", {
          decalId: decal.id,
          position: decal.position,
          size: decal.size,
          error,
        });
      }
    }
  }, [decal, targetMesh]);
 
  // Fade animation - update material opacity in animation loop
  useFrame((_, delta) => {
    if (!materialRef.current) return;
 
    // Accumulate age using delta time instead of Date.now() for better performance
    ageRef.current += delta;
    const fadeProgress = Math.min(ageRef.current / fadeDuration, 1);
    materialRef.current.opacity = decal.opacity * (1 - fadeProgress);
  });
 
  return (
    <mesh ref={meshRef} data-testid={`blood-decal-${decal.id}`}>
      <meshBasicMaterial
        ref={materialRef}
        map={texture}
        transparent
        opacity={decal.opacity}
        depthTest={true}
        depthWrite={false}
        polygonOffset
        polygonOffsetFactor={-4}
      />
    </mesh>
  );
};
 
/**
 * BloodDecals3D Component
 *
 * Renders persistent blood decals on 3D character models using decal projection.
 * Decals fade over time and can persist across combat rounds for injury tracking.
 *
 * Performance optimized:
 * - Limited concurrent decals (20 desktop, 10 mobile)
 * - Efficient decal geometry generation
 * - Texture reuse across all decals
 * - Automatic cleanup of faded decals
 *
 * @example
 * ```tsx
 * const [bloodDecals, setBloodDecals] = useState<BloodDecal[]>([]);
 * const characterMeshRef = useRef<THREE.Mesh>(null);
 *
 * // On hit event
 * const handleHit = (position: [number, number, number], normal: [number, number, number]) => {
 *   setBloodDecals([...bloodDecals, {
 *     id: generateId(),
 *     position,
 *     normal,
 *     size: [0.15, 0.15, 0.05],
 *     rotation: Math.random() * Math.PI * 2,
 *     opacity: 0.8,
 *     timestamp: Date.now(),
 *     isLaceration: false,
 *   }]);
 * };
 *
 * <mesh ref={characterMeshRef}>
 *   <capsuleGeometry args={[0.5, 1.6, 16, 32]} />
 *   <meshStandardMaterial color={0xcccccc} />
 * </mesh>
 *
 * <BloodDecals3D
 *   decals={bloodDecals}
 *   targetMeshRef={characterMeshRef}
 *   enabled={violenceSettings.blood}
 *   isMobile={isMobile}
 *   onDecalComplete={(id) => {
 *     setBloodDecals(prev => prev.filter(d => d.id !== id));
 *   }}
 * />
 * ```
 */
export const BloodDecals3D: React.FC<BloodDecals3DProps> = ({
  decals,
  targetMeshRef,
  enabled = true,
  isMobile = false,
  fadeDuration = DECAL_CONSTANTS.FADE_DURATION,
  onDecalComplete,
}) => {
  // Track completed decals
  const completedDecalsRef = useRef<Set<string>>(new Set());
 
  // Performance limits
  const maxDecals = isMobile
    ? DECAL_CONSTANTS.MAX_DECALS_MOBILE
    : DECAL_CONSTANTS.MAX_DECALS;
 
  // Create shared blood texture
  const bloodTexture = useMemo(() => createBloodTexture(), []);
 
  // Limit decals for performance
  const activeDecals = useMemo(() => {
    // Sort by timestamp (newest first) and take max count
    const sorted = [...decals].sort((a, b) => b.timestamp - a.timestamp);
    return sorted.slice(0, maxDecals);
  }, [decals, maxDecals]);
 
  // Track decal ages using delta accumulation for better performance
  const decalAgesRef = useRef<Map<string, number>>(new Map());
 
  // Initialize ages for new decals
  useEffect(() => {
    activeDecals.forEach((decal) => {
      if (!decalAgesRef.current.has(decal.id)) {
        decalAgesRef.current.set(decal.id, 0);
      }
    });
  }, [activeDecals]);
 
  // Check for completed decals using accumulated delta time
  useFrame((_, delta) => {
    activeDecals.forEach((decal) => {
      const currentAge = (decalAgesRef.current.get(decal.id) ?? 0) + delta;
      decalAgesRef.current.set(decal.id, currentAge);
      
      const isExpired = currentAge >= fadeDuration;
 
      if (
        isExpired &&
        onDecalComplete &&
        !completedDecalsRef.current.has(decal.id)
      ) {
        completedDecalsRef.current.add(decal.id);
        onDecalComplete(decal.id);
      }
    });
  });
 
  // Clean up texture on unmount
  // Note: This texture is shared across all decals for the lifetime of the component
  // to optimize memory usage and performance
  useEffect(() => {
    return () => {
      bloodTexture.dispose();
    };
  }, [bloodTexture]);
 
  // Use state to track target mesh, updated via layout effect to avoid ref access during render
  const [targetMesh, setTargetMesh] = React.useState<THREE.Mesh | undefined>();
 
  React.useLayoutEffect(() => {
    setTargetMesh(targetMeshRef?.current ?? undefined);
  }, [targetMeshRef]);
 
  // Don't render if disabled or no decals
  if (!enabled || activeDecals.length === 0) {
    return null;
  }
 
  return (
    <group data-testid="blood-decals-3d">
      {activeDecals.map((decal) => (
        <DecalMesh
          key={decal.id}
          decal={decal}
          texture={bloodTexture}
          targetMesh={targetMesh}
          fadeDuration={fadeDuration}
        />
      ))}
    </group>
  );
};
 
export default BloodDecals3D;