All files / components/shared/three/ui TechniqueBar.tsx

100% Statements 20/20
100% Branches 35/35
100% Functions 6/6
100% Lines 20/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 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                                                                                                                            4x                         162x 113x 113x 113x 113x         113x                     162x 1147x       162x 1147x 1147x       162x       162x                                         162x                       1147x 1147x 1147x   1147x                   2x                                                                                  
/**
 * TechniqueBar Component
 *
 * **Korean**: 기술 바 컴포넌트 (Technique Bar Component)
 *
 * Horizontal bar displaying 3-5 technique cards at the bottom-center of combat HUD.
 * Manages technique selection, resource availability, and cooldown states.
 *
 * @module components/shared/three/ui/TechniqueBar
 * @category Shared UI
 * @korean 기술바
 */
 
import React, { useMemo } from "react";
import { PlayerState } from "../../../../systems/player";
import { Technique } from "../../../../types";
import { TechniqueCard } from "./TechniqueCard";
 
/**
 * Props for TechniqueBar component.
 */
export interface TechniqueBarProps {
  /** Available techniques for player */
  readonly techniques: readonly Technique[];
 
  /** Player state with resources */
  readonly player: PlayerState;
 
  /** Index of currently selected technique */
  readonly selectedIndex: number;
 
  /** Active cooldowns map (techniqueId -> remaining ms) */
  readonly cooldowns: ReadonlyMap<string, number>;
 
  /** Callback when technique is selected */
  readonly onTechniqueSelect: (index: number) => void;
 
  /** Callback when hovering technique (for tooltip) */
  readonly onTechniqueHover: (technique: Technique | null) => void;
 
  /** Whether rendering for mobile device */
  readonly isMobile: boolean;
 
  /** Screen width for positioning */
  readonly screenWidth: number;
 
  /** Screen height for positioning */
  readonly screenHeight: number;
 
  /** Whether to use embedded mode (relative positioning, no absolute) */
  readonly embedded?: boolean;
}
 
/**
 * TechniqueBar Component
 *
 * Displays a horizontal bar of technique cards positioned at the bottom-center
 * of the combat screen. Each card shows technique details and availability.
 *
 * @param props - Component props
 * @returns TechniqueBar component
 */
export const TechniqueBar: React.FC<TechniqueBarProps> = ({
  techniques,
  player,
  selectedIndex,
  cooldowns,
  onTechniqueSelect,
  onTechniqueHover,
  isMobile,
  screenWidth,
  screenHeight,
  embedded = false,
}) => {
  // Calculate card sizing and spacing
  const layout = useMemo(() => {
    const cardWidth = isMobile ? 70 : 90;
    const cardHeight = isMobile ? 80 : 100;
    const gap = isMobile ? 8 : 12;
    const totalWidth = Math.max(
      0,
      techniques.length * cardWidth + (techniques.length - 1) * gap,
    );
 
    return {
      cardWidth,
      cardHeight,
      gap,
      totalWidth,
      startX: (screenWidth - totalWidth) / 2,
      startY: screenHeight - cardHeight - (isMobile ? 100 : 120),
    };
  }, [techniques.length, isMobile, screenWidth, screenHeight]);
 
  // Check if player has sufficient resources for a technique
  const hasResources = (tech: Technique): boolean => {
    return player.stamina >= tech.staminaCost && player.ki >= tech.kiCost;
  };
 
  // Check if technique is available (has resources and not on cooldown)
  const isAvailable = (tech: Technique): boolean => {
    const onCooldown = (cooldowns.get(tech.id) ?? 0) > 0;
    return hasResources(tech) && !onCooldown;
  };
 
  // Calculate bottom position for proper placement (only used in non-embedded mode)
  const bottomOffset = isMobile ? 100 : 120;
 
  // Embedded mode: relative positioning inside parent container
  // Non-embedded: absolute positioning for standalone use
  const containerStyle: React.CSSProperties = embedded
    ? {
        position: "relative",
        display: "flex",
        justifyContent: "center",
        width: "100%",
        pointerEvents: "auto",
      }
    : {
        position: "absolute",
        left: "50%",
        bottom: `${bottomOffset}px`,
        transform: "translateX(-50%)",
        width: `${layout.totalWidth}px`,
        height: `${layout.cardHeight}px`,
        display: "flex",
        gap: `${layout.gap}px`,
        pointerEvents: "auto",
        zIndex: 100,
      };
 
  return (
    <>
      {/* Technique Bar Container */}
      <div style={containerStyle} data-testid="technique-bar">
        <div
          style={{
            display: "flex",
            gap: `${layout.gap}px`,
            justifyContent: "center",
          }}
        >
          {techniques.map((technique, index) => {
            const cardX = index * (layout.cardWidth + layout.gap);
            const cooldownRemaining = cooldowns.get(technique.id) ?? 0;
            const available = isAvailable(technique);
 
            return (
              <div key={technique.id} data-testid={`technique-slot-${index}`}>
                <TechniqueCard
                  technique={technique}
                  isSelected={selectedIndex === index}
                  isAvailable={available}
                  staminaCost={technique.staminaCost}
                  kiCost={technique.kiCost}
                  remainingCooldown={cooldownRemaining}
                  keyboardShortcut={technique.keyboardShortcut}
                  onClick={() => onTechniqueSelect(index)}
                  onHover={onTechniqueHover}
                  isMobile={isMobile}
                  playerArchetype={player.archetype}
                  playerStance={player.currentStance}
                  position={{ x: cardX, y: 0 }}
                />
              </div>
            );
          })}
        </div>
      </div>
 
      {/* Keyboard Hints - only shown in embedded mode or non-mobile */}
      {!isMobile && (
        <div
          style={{
            position: embedded ? "relative" : "absolute",
            left: embedded ? undefined : "50%",
            bottom: embedded
              ? undefined
              : `${bottomOffset - layout.cardHeight - 20}px`,
            transform: embedded ? undefined : "translateX(-50%)",
            width: embedded ? "100%" : `${layout.totalWidth}px`,
            textAlign: "center",
            fontSize: "11px",
            color: "#aaa",
            fontFamily: "monospace",
            pointerEvents: "none",
            textShadow: "0 0 4px rgba(0,0,0,0.8)",
            marginTop: embedded ? "8px" : undefined,
          }}
        >
          기술 실행: Q-E-R-T-Y-F-G-Z-X-C | Press technique keys to execute
        </div>
      )}
    </>
  );
};
 
export default TechniqueBar;