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 | 3x 3x 52x 3x 3x 3x 3x 58x 2x 56x 4x 4x 48x 3x 58x 52x 58x 58x 58x 52x 58x 55x 53x 53x 50x 50x 46x 58x 58x 58x 58x 58x | /**
* CombatTimer Component - Displays combat round timer
*
* Korean: 전투 타이머 (Combat Timer)
*
* Shows remaining time in MM:SS format with:
* - Color changes based on time remaining
* - Pulsing animation when time is critical
* - Korean cyberpunk aesthetic
* - Responsive sizing
*
* @module components/shared/ui/CombatTimer
* @category Shared UI
*/
import React, { useMemo, useEffect } from "react";
import { KOREAN_COLORS, FONT_FAMILY } from "@/types/constants";
import { hexColorToCSS } from "../../../utils/colorUtils";
import { TimerWarningLevel } from "../../../hooks/useCombatTimer";
import "../three/ui/HUDAnimations.css";
// Define CSS animation once at module level to avoid re-insertion
const PULSE_ANIMATION_ID = "combat-timer-pulse-animation";
const injectPulseAnimation = () => {
// Check if style already exists in DOM instead of using module-level flag
if (document.getElementById(PULSE_ANIMATION_ID)) return;
const style = document.createElement("style");
style.id = PULSE_ANIMATION_ID;
style.textContent = `
@keyframes pulse {
0% {
transform: translateX(-50%) scale(1);
opacity: 1;
}
50% {
transform: translateX(-50%) scale(1.05);
opacity: 0.9;
}
100% {
transform: translateX(-50%) scale(1);
opacity: 1;
}
}
`;
document.head.appendChild(style);
};
/**
* Props for the CombatTimer component
*/
export interface CombatTimerProps {
/** Formatted time string (MM:SS) */
readonly formattedTime: string;
/** Current warning level */
readonly warningLevel: TimerWarningLevel;
/** Whether time is up */
readonly isTimeUp: boolean;
/** Whether layout should adapt for mobile screens */
readonly isMobile: boolean;
/** Optional custom position styling */
readonly style?: React.CSSProperties;
}
/**
* Get timer color based on warning level
*/
function getTimerColor(
warningLevel: TimerWarningLevel,
isTimeUp: boolean,
): number {
if (isTimeUp) {
return KOREAN_COLORS.NEGATIVE_RED; // Red for time up
}
switch (warningLevel) {
case "urgent":
return KOREAN_COLORS.NEGATIVE_RED; // Red (≤5s)
case "warning":
return KOREAN_COLORS.SECONDARY_YELLOW; // Yellow (≤10s)
case "none":
default:
return KOREAN_COLORS.PRIMARY_CYAN; // Cyan (>10s)
}
}
/**
* CombatTimer Component
*
* Displays round timer with Korean cyberpunk aesthetic and responsive design.
*
* Features:
* - MM:SS format display
* - Color changes: cyan (>10s), yellow (≤10s), red (≤5s)
* - Pulsing animation when time ≤5s
* - "Time's Up!" message when timer reaches 0
* - Responsive sizing for mobile/tablet/desktop
* - Korean-English bilingual support
*
* Korean: 전투 타이머 컴포넌트
*
* @example
* ```tsx
* <CombatTimer
* formattedTime="03:45"
* warningLevel="none"
* isTimeUp={false}
* isMobile={false}
* />
* ```
*/
export const CombatTimer: React.FC<CombatTimerProps> = ({
formattedTime,
warningLevel,
isTimeUp,
isMobile,
style = {},
}) => {
// Inject CSS animation once on mount
useEffect(() => {
injectPulseAnimation();
}, []);
// Get timer color
const timerColor = getTimerColor(warningLevel, isTimeUp);
const timerColorCSS = useMemo(() => hexColorToCSS(timerColor), [timerColor]);
// Background color with opacity
const bgColor = useMemo(
() => hexColorToCSS(KOREAN_COLORS.UI_BACKGROUND_DARK),
[],
);
// Border color based on warning level
const borderColor = useMemo(() => {
if (isTimeUp) return hexColorToCSS(KOREAN_COLORS.NEGATIVE_RED);
if (warningLevel === "urgent")
return hexColorToCSS(KOREAN_COLORS.NEGATIVE_RED);
if (warningLevel === "warning")
return hexColorToCSS(KOREAN_COLORS.SECONDARY_YELLOW);
return hexColorToCSS(KOREAN_COLORS.PRIMARY_CYAN);
}, [warningLevel, isTimeUp]);
// Should flash when urgent or time is up
const shouldFlash = warningLevel === "urgent" || isTimeUp;
// Font sizes based on screen size
const fontSize = isMobile ? "32px" : "48px";
const labelFontSize = isMobile ? "12px" : "14px";
// Display text
const displayText = isTimeUp ? "시간 종료 | Time's Up!" : formattedTime;
return (
<div
data-testid="combat-timer"
role="timer"
aria-live="polite"
aria-atomic="true"
aria-label={`Time remaining: ${formattedTime}`}
className="hud-animated"
style={{
position: "absolute",
top: isMobile ? "8px" : "12px",
left: "50%",
transform: "translateX(-50%)",
fontSize,
fontFamily: "monospace",
fontWeight: "bold",
color: timerColorCSS,
textShadow: `0 0 ${isMobile ? "8px" : "12px"} ${timerColorCSS}, 0 0 ${
isMobile ? "16px" : "24px"
} ${timerColorCSS}`,
padding: isMobile ? "8px 16px" : "12px 24px",
backgroundColor: `${bgColor}dd`,
border: `2px solid ${borderColor}`,
borderRadius: isMobile ? "6px" : "8px",
animation: shouldFlash ? "timerFlash 1s ease-in-out infinite" : "none",
zIndex: 100,
pointerEvents: "none",
userSelect: "none",
minWidth: isMobile ? "120px" : "160px",
textAlign: "center",
boxShadow: `0 0 ${isMobile ? "12px" : "20px"} ${borderColor}40`,
transition: "all 0.3s ease-in-out",
...style,
}}
>
{/* Label */}
{!isTimeUp && (
<div
style={{
fontSize: labelFontSize,
fontFamily: FONT_FAMILY.KOREAN,
color: timerColorCSS,
marginBottom: "2px",
opacity: 0.8,
}}
>
시간 | TIME
</div>
)}
{/* Timer display */}
<div>{displayText}</div>
</div>
);
};
export default CombatTimer;
|