/** * 소근육 (MC) — 6단계 × 10초, 한 손 탭 + 한 손 스와이프 (PRD §3.1.1) * * MC_STEP_DATA = [true, false, true, false, true, false] * true → 왼쪽=탭(둥근 원), 오른쪽=스와이프(슬라이드 바) * false → 왼쪽=스와이프(슬라이드 바), 오른쪽=탭(둥근 원) * * 탭: 원을 누르면 터치로 집계. * 스와이프: 슬라이드 바의 노브(원)를 누른 채 좌우로 밀면 노브가 손을 따라 움직이고 * 최소 거리 이상 움직이면 스와이프 1회로 집계 (손을 떼면 노브는 중앙 복귀). * 자기 손에 주어진 동작을 하면 정답(correct), 반대 동작을 하면 오답 카운트. * 좌/우가 서로 독립적으로 동시에 동작하므로(멀티터치) 한 손은 탭, 한 손은 스와이프를 동시 수행할 수 있다. */ import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import styled from 'styled-components'; import TestRunner from '../components/TestRunner'; import { http } from '../lib/api'; import { MC_STEP_DATA, MC_STEP_DURATION, INTRO_DATA } from '../data/tests'; /* ── 결과 타입 ─── */ interface StepCounts { condition: boolean; leftCorrectTouchCount: number; leftTotalTouchCount: number; leftCorrectSwipeCount: number; leftTotalSwipeCount: number; rightCorrectTouchCount: number; rightTotalTouchCount: number; rightCorrectSwipeCount: number; rightTotalSwipeCount: number; } const emptyCounts = (condition: boolean): StepCounts => ({ condition, leftCorrectTouchCount: 0, leftTotalTouchCount: 0, leftCorrectSwipeCount: 0, leftTotalSwipeCount: 0, rightCorrectTouchCount: 0, rightTotalTouchCount: 0, rightCorrectSwipeCount: 0, rightTotalSwipeCount: 0, }); type Side = 'left' | 'right'; type Mode = 'tap' | 'swipe'; /** 포인터별 진행 중 제스처 상태 (스와이프 거리 판정용) */ interface Gesture { side: Side; startX: number; startY: number; minX: number; maxX: number; minGap: number; } /* ── 스타일 ─── */ const Board = styled.div` flex: 1; min-height: 0; display: flex; gap: 12px; `; const Side = styled.div` flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; touch-action: none; user-select: none; -webkit-user-select: none; `; const SideLabel = styled.div` font-size: 14px; font-weight: 800; color: var(--pcnt-text); `; const ModeBadge = styled.span<{ mode: Mode }>` font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 999px; color: #fff; background: ${(p) => (p.mode === 'tap' ? '#3498db' : '#e67e22')}; `; /* 탭 사이드 — 둥근 원 */ const TapWrap = styled.div` position: relative; width: clamp(120px, 28vh, 190px); height: clamp(120px, 28vh, 190px); display: flex; align-items: center; justify-content: center; `; const Ring = styled.div` position: absolute; inset: 0; border-radius: 50%; border: 3px solid #3498db; animation: mc-ring 1.6s ease-out infinite; @keyframes mc-ring { 0% { transform: scale(0.85); opacity: 0.9; } 70% { transform: scale(1.2); opacity: 0; } 100% { transform: scale(1.2); opacity: 0; } } `; const TapCircle = styled.div` width: clamp(96px, 22vh, 150px); height: clamp(96px, 22vh, 150px); border-radius: 50%; background: #3498db; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800; box-shadow: 0 6px 18px rgba(52, 152, 219, 0.35); `; /* 스와이프 사이드 — 노브가 있는 슬라이드 바 */ const SliderWrap = styled.div` position: relative; width: clamp(200px, 44vw, 340px); max-width: 92%; height: clamp(20px, 5vh, 30px); border-radius: 999px; border: 3px solid #e67e22; background: linear-gradient(90deg, #fff 0%, #fff3e0 50%, #fff 100%); box-shadow: 0 4px 14px rgba(230, 126, 34, 0.28); `; const CenterMark = styled.div` position: absolute; top: -6px; bottom: -6px; left: 50%; width: 2px; margin-left: -1px; background: rgba(230, 126, 34, 0.35); border-radius: 2px; pointer-events: none; `; const Knob = styled.div<{ dragging: boolean }>` position: absolute; top: 50%; width: clamp(28px, 7vh, 44px); height: clamp(28px, 7vh, 44px); border-radius: 50%; background: #e67e22; border: 3px solid #fff; box-shadow: 0 3px 10px rgba(230, 126, 34, 0.5); transform: translate(-50%, -50%); transition: left ${(p) => (p.dragging ? '0ms' : '180ms')} ease-out; pointer-events: none; /* 이벤트는 사이드 영역(Side)에서 처리 */ `; /* ── 컴포넌트 ─── */ export default function TestMC() { const countsRef = useRef(emptyCounts(true)); const condRef = useRef(true); // 현재 단계의 조건 (true → L=탭) const gesturesRef = useRef>({}); const lastIndexRef = useRef(0); const trackRef = useRef(null); const [knob, setKnob] = useState(0.5); // 슬라이드 노브 위치 (0~1, 0.5=중앙) const [dragging, setDragging] = useState(false); const startStep = (index: number) => { condRef.current = MC_STEP_DATA[index]; countsRef.current = emptyCounts(condRef.current); gesturesRef.current = {}; setKnob(0.5); setDragging(false); }; /** 해당 사이드가 이번 단계에서 '탭' 역할인지 */ const isTapSide = (side: Side): boolean => (side === 'left') === condRef.current; const isSwipeSide = (side: Side): boolean => !isTapSide(side); const countTouch = (side: Side) => { const c = countsRef.current; const correct = isTapSide(side); if (side === 'left') { c.leftTotalTouchCount += 1; if (correct) c.leftCorrectTouchCount += 1; } else { c.rightTotalTouchCount += 1; if (correct) c.rightCorrectTouchCount += 1; } }; const countSwipe = (side: Side) => { const c = countsRef.current; const correct = isSwipeSide(side); if (side === 'left') { c.leftTotalSwipeCount += 1; if (correct) c.leftCorrectSwipeCount += 1; } else { c.rightTotalSwipeCount += 1; if (correct) c.rightCorrectSwipeCount += 1; } }; /** 슬라이드 노브를 손가락 X 좌표로 이동 */ const moveKnobTo = (clientX: number) => { const el = trackRef.current; if (!el) return; const r = el.getBoundingClientRect(); const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width)); setKnob(f); }; /* ── 포인터 제스처 ─── */ const onPointerDown = (side: Side) => (e: ReactPointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); gesturesRef.current[e.pointerId] = { side, startX: e.clientX, startY: e.clientY, minX: e.clientX, maxX: e.clientX, minGap: Math.max(r.width / 5, 36), }; if (isSwipeSide(side)) setDragging(true); try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 미지원 브라우저 대비 */ } }; const onPointerMove = (side: Side) => (e: ReactPointerEvent) => { const g = gesturesRef.current[e.pointerId]; if (!g || g.side !== side) return; // 스와이프 사이드면 노브가 손가락을 따라 움직임 if (isSwipeSide(side)) moveKnobTo(e.clientX); g.minX = Math.min(g.minX, e.clientX); g.maxX = Math.max(g.maxX, e.clientX); // 최소 거리 이상 이동 → 스와이프 1회 집계 (원본 로직 동일, 연속 밀기 가능) if (g.maxX - g.minX > g.minGap) { countSwipe(side); g.minX = e.clientX; g.maxX = e.clientX; } }; const onPointerUp = (side: Side) => (e: ReactPointerEvent) => { const g = gesturesRef.current[e.pointerId]; delete gesturesRef.current[e.pointerId]; if (isSwipeSide(side)) { setDragging(false); setKnob(0.5); // 놓으면 노브는 중앙으로 복귀 } if (!g || g.side !== side) return; // 거의 이동 없이 누르고 뗐다면 탭으로 집계 const dx = e.clientX - g.startX; const dy = e.clientY - g.startY; if (Math.hypot(dx, dy) < 14) { countTouch(side); } }; const onPointerCancel = (side: Side) => (e: ReactPointerEvent) => { if (gesturesRef.current[e.pointerId]?.side === side) { delete gesturesRef.current[e.pointerId]; } if (isSwipeSide(side)) { setDragging(false); setKnob(0.5); } }; const renderSide = (side: Side, mode: Mode) => { const isLeft = side === 'left'; return ( {isLeft ? '왼쪽 손' : '오른쪽 손'} {mode === 'tap' ? ( <> 탭 · 원을 누르세요 ) : ( <> 스와이프 · 노브를 좌우로 밀기 )} ); }; return ( { if (lastIndexRef.current !== index) { lastIndexRef.current = index; startStep(index); } const cond = MC_STEP_DATA[index]; return ( {renderSide('left', cond ? 'tap' : 'swipe')} {renderSide('right', cond ? 'swipe' : 'tap')} ); }} onTimeout={(_index, record) => record({ ...countsRef.current, playTime: MC_STEP_DURATION })} onSubmit={async (results) => { await http.put('/api/cntdata/MC', { data: results }); }} /> ); }