All files / hooks useCombatTimer.ts

96.29% Statements 52/54
94.44% Branches 34/36
100% Functions 8/8
96.15% Lines 50/52

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                                                                                                                          102x 102x 102x                         102x 9x   93x 23x   70x                                                                               102x   102x 102x 102x 102x 102x     102x 53x 53x 53x       102x   101x 35x       35x       66x 66x     66x 175x 175x   175x     175x 2x 2x 2x 2x   2x       66x 66x 64x 64x           102x             102x 100x 39x     36x           10x 10x       36x           5x 5x                         102x   102x              
/**
 * useCombatTimer - Hook for managing combat round timer
 *
 * Korean: 전투 라운드 타이머 훅 (Combat Round Timer Hook)
 *
 * Manages countdown timer for combat rounds with:
 * - Pause/resume support
 * - Warning thresholds at 10s and 5s
 * - Audio alerts for warnings
 * - Time's up callback
 *
 * @module hooks/useCombatTimer
 * @category Combat Hooks
 */
 
import { useEffect, useRef, useState } from "react";
import { useAudio } from "../audio/AudioProvider";
 
/**
 * Timer warning level indicating urgency
 */
export type TimerWarningLevel = "none" | "warning" | "urgent";
 
/**
 * Configuration for the combat timer hook
 */
export interface UseCombatTimerConfig {
  /** Initial time in seconds */
  readonly initialTime: number;
  /** Whether the timer is paused */
  readonly isPaused: boolean;
  /** Callback when timer reaches 0 */
  readonly onTimeUp: () => void;
  /** Warning threshold in seconds (default: 10) */
  readonly warningThreshold?: number;
  /** Urgent warning threshold in seconds (default: 5) */
  readonly urgentThreshold?: number;
  /** Optional key to force timer reset (e.g., round number) */
  readonly resetKey?: string;
}
 
/**
 * Return value from useCombatTimer hook
 */
export interface UseCombatTimerReturn {
  /** Current time remaining in seconds */
  readonly timeRemaining: number;
  /** Current warning level */
  readonly warningLevel: TimerWarningLevel;
  /** Whether timer has reached 0 */
  readonly isTimeUp: boolean;
  /** Formatted time string (MM:SS) */
  readonly formattedTime: string;
}
 
/**
 * Format seconds into MM:SS format
 * @param seconds - Time in seconds
 * @returns Formatted string (e.g., "03:45", "00:05")
 */
function formatTime(seconds: number): string {
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = Math.floor(seconds % 60);
  return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
    .toString()
    .padStart(2, "0")}`;
}
 
/**
 * Get warning level based on time remaining
 */
function getWarningLevel(
  timeRemaining: number,
  warningThreshold: number,
  urgentThreshold: number
): TimerWarningLevel {
  if (timeRemaining <= urgentThreshold) {
    return "urgent";
  }
  if (timeRemaining <= warningThreshold) {
    return "warning";
  }
  return "none";
}
 
/**
 * useCombatTimer Hook
 *
 * Manages combat round countdown timer with pause support and audio warnings.
 *
 * Features:
 * - Counts down from initial time to 0
 * - Pauses/resumes based on isPaused prop
 * - Plays audio warning at warning threshold (default 10s)
 * - Plays urgent audio warning at urgent threshold (default 5s)
 * - Calls onTimeUp when timer reaches 0
 * - Provides formatted time string (MM:SS)
 * - Returns current warning level for UI styling
 *
 * Korean: 전투 라운드 타이머 관리 훅
 *
 * @example
 * ```tsx
 * const { timeRemaining, warningLevel, formattedTime } = useCombatTimer({
 *   initialTime: 180, // 3 minutes
 *   isPaused: false,
 *   onTimeUp: () => handleRoundEnd(),
 *   warningThreshold: 10,
 *   urgentThreshold: 5,
 * });
 * ```
 */
export function useCombatTimer(
  config: UseCombatTimerConfig
): UseCombatTimerReturn {
  const {
    initialTime,
    isPaused,
    onTimeUp,
    warningThreshold = 10,
    urgentThreshold = 5,
    resetKey,
  } = config;
 
  const audio = useAudio();
  const [timeRemaining, setTimeRemaining] = useState(initialTime);
  const [isTimeUp, setIsTimeUp] = useState(false);
  const lastWarningRef = useRef<TimerWarningLevel>("none");
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
 
  // Reset timer when initialTime or resetKey changes (new round)
  useEffect(() => {
    setTimeRemaining(initialTime);
    setIsTimeUp(false);
    lastWarningRef.current = "none";
  }, [initialTime, resetKey]);
 
  // Timer countdown logic
  useEffect(() => {
    // Don't run if paused or time is up
    if (isPaused || isTimeUp) {
      Iif (intervalRef.current) {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
      }
      return;
    }
 
    // Track start time for precise elapsed time calculation
    const startTimeRef = Date.now();
    const startingTimeRemaining = timeRemaining;
 
    // Start interval
    intervalRef.current = setInterval(() => {
      const elapsed = (Date.now() - startTimeRef) / 1000;
      const next = Math.max(0, startingTimeRemaining - elapsed);
 
      setTimeRemaining(next);
 
      // Check if time just reached 0
      if (next <= 0 && !isTimeUp) {
        setIsTimeUp(true);
        Eif (intervalRef.current) {
          clearInterval(intervalRef.current);
          intervalRef.current = null;
        }
        onTimeUp();
      }
    }, 100);
 
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
      }
    };
  }, [isPaused, isTimeUp, onTimeUp]);
 
  // Warning level calculation
  const warningLevel = getWarningLevel(
    timeRemaining,
    warningThreshold,
    urgentThreshold
  );
 
  // Audio warnings
  useEffect(() => {
    if (!audio.isAudioReady) return;
    if (isPaused) return;
 
    // Play warning sound at threshold
    if (
      warningLevel === "warning" &&
      lastWarningRef.current === "none" &&
      timeRemaining <= warningThreshold &&
      timeRemaining > urgentThreshold
    ) {
      audio.playSFX("attack_light"); // Placeholder - will be timer_warning_10s
      lastWarningRef.current = "warning";
    }
 
    // Play urgent warning sound at urgent threshold
    if (
      warningLevel === "urgent" &&
      lastWarningRef.current !== "urgent" &&
      timeRemaining <= urgentThreshold &&
      timeRemaining > 0
    ) {
      audio.playSFX("attack_heavy"); // Placeholder - will be timer_warning_5s
      lastWarningRef.current = "urgent";
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    warningLevel,
    timeRemaining,
    audio.isAudioReady,
    isPaused,
    warningThreshold,
    urgentThreshold,
  ]); // audio.playSFX is stable after initialization, omitted to prevent unnecessary re-renders
 
  // Format time for display
  const formattedTime = formatTime(timeRemaining);
 
  return {
    timeRemaining,
    warningLevel,
    isTimeUp,
    formattedTime,
  };
}