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 | 4x 32x 32x 32x 30x 2x 2x 2x 2x 32x 32x 32x 32x 32x 30x 2x 1x 1x 1x 1x 1x 4x | /**
* BloodLossOverlayHtml Component - Visual warning for blood loss
*
* Displays a pulsing red overlay when blood loss exceeds critical threshold (50%).
* Uses CSS animation for smooth pulsing effect.
*
* NOTE: This component is rendered OUTSIDE the Canvas as part of the HTML overlay.
* It does NOT use Html from drei - it's a standard React component.
*
* @module components/combat/BloodLossOverlayHtml
* @category Combat UI
* @korean 출혈오버레이
*/
import React, { useMemo } from "react";
import { KOREAN_COLORS } from "../../../../../types/constants";
export interface BloodLossOverlayProps {
/**
* Current blood loss amount (0-100)
* @korean 출혈량
*/
readonly bloodLoss: number;
/**
* Mobile responsive mode (reduced pulse intensity)
* @korean 모바일여부
*/
readonly isMobile: boolean;
/**
* Multiplier applied to the effect's maximum opacity (0.0-1.0).
*
* Use this to soften the fullscreen effect when the 3D arena is already
* visually compressed (e.g. portrait mobile). Default is `1.0`.
*
* @korean 효과강도배수
*/
readonly intensityScale?: number;
}
/**
* BloodLossOverlayHtml - Pulsing red warning for critical blood loss
*
* Renders a fullscreen pulsing red overlay when blood loss is 50 or higher.
* Only visible when bloodLoss is 50 or above; does not render for values below 50.
* Uses CSS keyframe animation for smooth pulsing effect at 60fps.
*
* Optimized with React.memo for 60fps performance:
* - Prevents re-renders when blood loss hasn't changed significantly
* - Memoized style calculations
*
* Accessibility notes:
* - Purely decorative visual effect; no ARIA role or aria-live region is defined here
* - Typically rendered within an aria-hidden container so it is ignored by screen readers
* - Critical status announcements should be provided by separate, semantic HUD components
*
* @example
* ```tsx
* // Overlay is rendered (bloodLoss >= 50)
* <BloodLossOverlayHtml bloodLoss={75} isMobile={false} />
*
* // Overlay is not rendered (bloodLoss < 50)
* <BloodLossOverlayHtml bloodLoss={30} isMobile={false} />
* ```
*/
export const BloodLossOverlayHtml = React.memo<BloodLossOverlayProps>(
({ bloodLoss, isMobile, intensityScale = 1 }) => {
const overlayStyle = useMemo(() => {
const criticalThreshold = 50;
if (bloodLoss < criticalThreshold) {
return null;
}
const clampedBloodLoss = Math.max(
criticalThreshold,
Math.min(100, bloodLoss)
);
const intensity =
(clampedBloodLoss - criticalThreshold) / (100 - criticalThreshold);
const safeScale = Math.max(0, Math.min(1, intensityScale));
const maxOpacity = (isMobile ? 0.15 : 0.25) * safeScale;
const baseOpacity = intensity * maxOpacity;
const rgb = KOREAN_COLORS.BLOODLOSS_INDICATOR;
const bloodColor = `rgb(${(rgb >> 16) & 255}, ${(rgb >> 8) & 255}, ${
rgb & 255
})`;
return {
position: "fixed" as const,
inset: 0,
pointerEvents: "none" as const,
backgroundColor: bloodColor,
["--base-opacity"]: baseOpacity.toString(),
animation: "bloodLossPulse 1.5s ease-in-out infinite",
transition: "opacity 0.5s ease-out",
zIndex: 55, // Between pain vignette and consciousness blur
} as React.CSSProperties;
}, [bloodLoss, isMobile, intensityScale]);
if (bloodLoss < 50 || !overlayStyle) {
return null;
}
return (
<>
{/* CSS keyframe animation for pulsing with CSS variables */}
<style>
{`
@keyframes bloodLossPulse {
0%, 100% {
opacity: var(--base-opacity);
}
50% {
opacity: calc(var(--base-opacity) * 1.5);
}
}
`}
</style>
<div
data-testid="bloodloss-overlay"
style={overlayStyle}
aria-hidden="true"
/>
</>
);
},
(prevProps, nextProps) => {
const wasCritical = prevProps.bloodLoss >= 50;
const isCritical = nextProps.bloodLoss >= 50;
Iif (!wasCritical && !isCritical) return true;
Iif (wasCritical !== isCritical) return false;
return (
Math.abs(prevProps.bloodLoss - nextProps.bloodLoss) < 5 &&
prevProps.isMobile === nextProps.isMobile &&
prevProps.intensityScale === nextProps.intensityScale
);
},
);
BloodLossOverlayHtml.displayName = "BloodLossOverlayHtml";
|