All files / components/shared/three/ui 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                                                                5x         380x     380x 256x       256x       380x 258x 258x 258x     258x   258x       380x 371x       9x 380x 380x     380x   380x                                                                                                                                             5x  
/**
 * 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";