/** * N-Back (NB) — 수정사항-021/021-2/021-3/021-4 * * 수정사항-021: 동작 로직 (준비 단계, 레벨 전환 카운트다운, 70개 결과 기록, 500ms 슬라이드) * 수정사항-021-2: 시각 — 가로형 "숫자 기차", 레벨별 셀 수, 고정 레이아웃 * 수정사항-021-3: 시각 — 버튼 크기 축소 + 버튼 숨김→비활성(흐림), 중간 칸 과거 숫자→빈 칸 * 수정사항-021-4: 시각 — 힌트 텍스트 공통화, [ ? ] 박스 고정 (숫자 박스만 우→좌 슬라이드) * * 동작: * - 기차: [ ? (고정) ] + [빈 칸들] + [현재 숫자] — 레벨별 셀 수 변경, '?'는 슬라이드 안 함 * 1-Back: [?][현재] / 2-Back: [?][ ][현재] / 3-Back: [?][ ][ ][현재] * - 레벨 전환(1→2→3-Back) 시3초 카운트다운 오버레이 * - 각 레벨 첫 n개 값은 "준비" 단계(표시만, 답변 불가, 기차 슬라이드) → 총70개 결과 기록 * - [같음][다름] 버튼은 항상 표시하되 애니메이션/준비 중에는 disabled(흐리게) 표시 * - 힌트: 1/2/3-Back 공통 안내문 (수정사항-021-4) * - 단계당3초 타이머, 타임아웃 = 오답(correct:null) */ import { useMemo, useRef, useState } from 'react'; import styled, { keyframes } from 'styled-components'; import TestRunner from '../components/TestRunner'; import SameDiffButtons from '../components/SameDiffButtons'; import NBackTrain from '../components/NBackTrain'; import { buildNBackSteps, NB_ANIMATION_MS, NB_STEP_DURATION, INTRO_DATA, type NBStep } from '../data/tests'; import { http } from '../lib/api'; /* ─── 스타일 ─── */ const Stage = styled.div` flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0; `; /** 숫자 영역: 크기가 고정되어 버튼 유무에 요동 없음 */ const NumberArea = styled.div` flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; `; /** 버튼 영역: 항상 동일한 높이 확보 (수정사항-021-3 — 숨기지 않고 버튼 자체를 disabled 처리) */ const ButtonArea = styled.div` flex: 0 0 auto; width: 100%; padding: 8px 16px 4px; `; const Hint = styled.p` color: var(--pcnt-text-sub); font-size: 14px; `; const pop = keyframes` from { transform: scale(1.5); opacity: 0.3; } to { transform: scale(1); opacity: 1; } `; const TransitionOverlay = styled.div` flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #1f2430; `; const CountNum = styled.div` font-size: 88px; font-weight: 800; animation: ${pop} 0.6s ease-out; `; const TransitionTitle = styled.p` font-size: 18px; font-weight: 700; `; /* ─── 컴포넌트 ─── */ export default function TestNB() { const steps = useMemo(() => buildNBackSteps(), []); const [animating, setAnimating] = useState(false); const animatingRef = useRef(false); const totalSteps = steps.length; /** 500ms 슬라이드 애니메이션 후 콜백 실행 (중복 호출 가드 포함) */ const playTransition = (done: () => void) => { if (animatingRef.current) return; animatingRef.current = true; setAnimating(true); window.setTimeout(() => { animatingRef.current = false; setAnimating(false); done(); }, NB_ANIMATION_MS); }; const onSubmit = async (r: unknown[]) => { // 전환/준비 단계는 기록하지 않으므로 결과 배열에 undefined 구멍이 생긴다. // JSON 직렬화 시 null로 내려가 백엔드 calc_nback이 None.get으로 크래시 → 제거 후 전송. await http.put('/api/cntdata/NBACK', { data: r.filter((x) => x != null) }); }; const onTimeout = (index: number, record: (r: unknown) => void, _advance: () => void) => { const s = steps[index]; // 전환 단계는 애니메이션 없이 기록 없이 다음으로 (이전 RN과 동일). // 주의: TestRunner의 handleTimeout이 onTimeout 호출 전에 advancedRef=true를 // 설정하므로 advance()는 no-op이 된다 → record(null)로 진행 (제출 시 null 필터링). if (s.kind === 'transition') { record(null); return; } // 준비 단계도 기차 슬라이드 후 다음으로 if (s.kind === 'prime') { playTransition(() => record(null)); return; } // question 타임아웃 = 오답, 500ms 슬라이드 후 correct:null 기록 if (animatingRef.current) return; playTransition(() => record({ playTime: NB_STEP_DURATION, nBack: s.nback, correct: null }), ); }; return ( { const s = steps[index]; // 레벨 전환: "N-Back 시작" + 3초 카운트다운 (playTime으로 파생) if (s.kind === 'transition') { const remain = Math.max(0, Math.ceil((NB_STEP_DURATION - playTime) / 1000)); return ( {remain > 0 ? remain : '시작!'} {s.to}-Back 테스트 시작 ); } const nextStep = steps[index + 1]; const nextValue = nextStep && (nextStep.kind === 'question' || nextStep.kind === 'prime') ? nextStep.value : null; // 공통: 숫자 영역 + 버튼 영역 (항상 같은 구조, 요동 없음) // 버튼은 숨기지 않고 애니메이션/준비 중에는 disabled(흐리게) 표시 (수정사항-021-3) return ( {s.kind === 'prime' ? ( {s.nback}-Back · 준비 중 ) : ( 물음표에 들어갈 숫자와 현재 보이는 숫자가 같다면 [같음] 버튼을, 다르다면 [다름] 버튼을 눌러주세요 )} { if (animatingRef.current) return; playTransition(() => record({ playTime, nBack: s.nback, correct: s.isMatch === same }), ); }} /> ); }} onTimeout={onTimeout} onSubmit={onSubmit} /> ); }