All files / components/screens/combat/components/indicators BreathingIndicator.tsx

95.45% Statements 21/22
100% Branches 17/17
83.33% Functions 5/6
95% Lines 19/20

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                                                                4x         105x     105x 102x       102x       105x 104x 104x 104x     104x   104x       105x 96x       9x 105x 105x     105x   105x                                                                                                                                             4x  
/**
 * BreathingIndicator Component - Visual feedback for breathing disruption status
 * 
 * **Korean**: 호흡곤란 표시기
 * 
 * Displays breathing difficulty with:
 * - Color-coded lungs icon (🫁)
 * - Bilingual label (Korean | English)
 * - Time remaining until recovery
 * - Pulsing animation based on severity
 */
 
import React, { useEffect, useMemo, useState } from "react";
import {
  BreathingDisruptionSystem,
  createBreathingIndicator,
} from "../../../../../systems/breathing";
import { PlayerState } from "../../../../../systems/player";
import { FONT_FAMILY } from "../../../../../types/constants";
import { hexToRgbaString } from "../../../../../utils/colorUtils";
import "./BreathingIndicator.css";
 
export interface BreathingIndicatorProps {
  /** Player state to check for breathing disruption */
  readonly player: PlayerState;
  /** Whether to use mobile-optimized sizing */
  readonly isMobile?: boolean;
}
 
/**
 * BreathingIndicator - Shows breathing disruption status with Korean-English labels
 */
export const BreathingIndicator: React.FC<BreathingIndicatorProps> = ({
  player,
  isMobile = false,
}) => {
  // Use state to trigger re-renders for timer updates
  const [currentTime, setCurrentTime] = useState(() => Date.now());
 
  // Update current time every 100ms to keep the timer accurate
  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentTime(Date.now());
    }, 100);
 
    return () => clearInterval(interval);
  }, []);
 
  // Get current breathing disruption state
  const breathingState = useMemo(() => {
    const level = BreathingDisruptionSystem.getCurrentLevel(player);
    const activeEffect = BreathingDisruptionSystem.getActiveEffect(player);
    const timeRemaining = activeEffect
      ? Math.max(0, activeEffect.endTime - currentTime)
      : 0;
    const isRecovering = BreathingDisruptionSystem.canRecover(player);
 
    return createBreathingIndicator(level, timeRemaining, isRecovering);
  }, [player, currentTime]);
 
  // Don't render if no breathing disruption
  if (!breathingState.visible) {
    return null;
  }
 
  // Responsive sizing
  const iconSize = isMobile ? 24 : 32;
  const fontSize = isMobile ? 10 : 12;
  const padding = isMobile ? "4px 8px" : "6px 12px";
 
  // Format time remaining
  const secondsRemaining = Math.ceil(breathingState.timeRemaining / 1000);
 
  return (
    <div
      data-testid="breathing-indicator"
      style={{
        display: "flex",
        alignItems: "center",
        gap: isMobile ? "6px" : "8px",
        padding,
        backgroundColor: "rgba(0, 0, 0, 0.7)",
        borderRadius: "8px",
        border: `2px solid ${hexToRgbaString(breathingState.color, breathingState.opacity)}`,
        boxShadow: `0 0 10px ${hexToRgbaString(breathingState.color, 0.5)}`,
        animation: "breathing-pulse 1s ease-in-out infinite",
        pointerEvents: "none",
      }}
    >
        {/* Lungs icon */}
        <div
          data-testid="breathing-icon"
          style={{
            fontSize: `${iconSize}px`,
            lineHeight: 1,
            transform: `scale(${breathingState.scale})`,
            filter: `drop-shadow(0 0 6px ${hexToRgbaString(breathingState.color, 0.6)})`,
          }}
        >
          {breathingState.icon}
        </div>
 
        {/* Label and time */}
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            gap: "2px",
          }}
        >
          {/* Bilingual label */}
          <div
            data-testid="breathing-label"
            style={{
              fontSize: `${fontSize}px`,
              fontFamily: FONT_FAMILY.KOREAN,
              fontWeight: "bold",
              color: hexToRgbaString(breathingState.color, 1),
              textShadow: `0 0 4px ${hexToRgbaString(breathingState.color, 0.8)}`,
              whiteSpace: "nowrap",
            }}
          >
            {breathingState.label.korean} | {breathingState.label.english}
          </div>
 
          {/* Time remaining */}
          <div
            data-testid="breathing-timer"
            style={{
              fontSize: `${fontSize - 2}px`,
              fontFamily: FONT_FAMILY.KOREAN,
              color: breathingState.isRecovering
                ? hexToRgbaString(0x00ff00, 0.8)
                : hexToRgbaString(0xffffff, 0.6),
              whiteSpace: "nowrap",
            }}
          >
            {breathingState.isRecovering ? "회복중 | Recovering" : `${secondsRemaining}s`}
          </div>
        </div>
      </div>
  );
};
 
BreathingIndicator.displayName = "BreathingIndicator";