/** * 카드정렬 (CST) — 30단계(3장×10/4장×10/5장×10), 드래그 정렬, 5/10/15초 (PRD §3.1.3) * 패턴의 0행=guide(목표), 1행=mission(초기) — mission을 guide와 일치시키면 정답. * 목표는 좌측, 내 카드는 우측에 배치. 카드마다 색상을 무작위로 섞는다. */ import { useRef, useState } from 'react'; import styled from 'styled-components'; import TestRunner from '../components/TestRunner'; import { CST_CARD_COLORS, CST_DIFFICULTY, CST_EASY_PATTERNS, CST_HARD_PATTERNS, CST_NORMAL_PATTERNS, CST_STEP_SIZES, CSTPattern, CSTPatternCell, INTRO_DATA, } from '../data/tests'; import { http } from '../lib/api'; const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; interface Card { id: number; img: string; } type Col = Card[]; interface CSTStep { difficulty: 'C3' | 'C4' | 'C5'; duration: number; guide: Col[]; mission: Col[]; } function buildStep(cardNum: 3 | 4 | 5, pattern: CSTPattern): CSTStep { const used = new Set(); const imgs: Record = {}; for (let k = 0; k < cardNum; k++) { let cid = rand(1, 18); while (used.has(cid)) cid = rand(1, 18); used.add(cid); // 카드마다 서로 다른 색상 (기존: 단계 전체 한 색 → 구분 불가) const color = CST_CARD_COLORS[rand(0, CST_CARD_COLORS.length - 1)]; imgs[k] = `images/card/${color.label}/${color.file(cid)}`; } const toCol = (cell: CSTPatternCell): Col => cell.map((c) => ({ id: c.id, img: imgs[c.id] })); return { difficulty: CST_DIFFICULTY[cardNum].code, duration: CST_DIFFICULTY[cardNum].duration, guide: pattern[0].map(toCol), mission: pattern[1].map(toCol), }; } /** * 세션당 30단계를 새로 생성한다. * 이전에는 모듈 로드 시 1회만 생성해 매 테스트가 같은 배열로 나왔다. (수정사항-020-2) * 또한 각 난이도 패턴 순서를 셔플해 세션마다 배열이 달라지도록 한다. */ function buildSessionSteps(): CSTStep[] { const shuffle = (arr: readonly T[]): T[] => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = rand(0, i); [a[i], a[j]] = [a[j], a[i]]; } return a; }; const easy = shuffle(CST_EASY_PATTERNS); const normal = shuffle(CST_NORMAL_PATTERNS); const hard = shuffle(CST_HARD_PATTERNS); return CST_STEP_SIZES.map((n, i) => { const patterns = n === 3 ? easy : n === 4 ? normal : hard; return buildStep(n, patterns[i % patterns.length]); }); } /* ── 스타일 ─── */ const Board = styled.div` flex: 1; display: flex; gap: 32px; /* 좌측 목표 ↔ 우측 내 카드 여백 강조 */ min-height: 0; padding: 0 8px; `; const Panel = styled.div` flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; `; const RowLabel = styled.div` font-size: 13px; color: var(--pcnt-text-sub); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; padding-bottom: 4px; `; const Columns = styled.div` display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; min-height: 0; flex: 1; `; const ColBox = styled.div` background: #e4e8f1; border-radius: 12px; border: 2px solid #d1d6e4; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 3px; padding: 6px; min-height: 0; `; const CardImg = styled.div<{ img: string; drag?: boolean; dx?: number }>` width: 100%; max-width: 64px; aspect-ratio: 4 / 5; border-radius: 8px; background: url('/${(p) => p.img}') center/cover no-repeat, #fff; background-color: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); cursor: ${(p) => (p.drag ? 'grab' : 'default')}; transform: translateX(${(p) => p.dx ?? 0}px); transition: transform 0.08s; touch-action: none; position: relative; z-index: ${(p) => (p.drag ? 5 : 1)}; `; /* ── 컴포넌트 ─── */ export default function TestCST() { // 세션당 30단계를 새로 생성 (모듈 상수가 아니므로 매 테스트마다 배열이 달라짐) const [steps] = useState(buildSessionSteps); const [board, setBoard] = useState(() => steps[0].mission.map((c) => [...c])); const [moves, setMoves] = useState(0); const [drag, setDrag] = useState<{ col: number; dx: number } | null>(null); const dragRef = useRef<{ col: number; cardId: number; startX: number } | null>(null); // 미션(내 카드) 열 그리드 — 드롭 위치 → 열 인덱스 계산용 const missionGridRef = useRef(null); const lastIndexRef = useRef(0); const recordRef = useRef<(r: unknown) => void>(() => {}); const playTimeRef = useRef(0); const solvedRef = useRef(false); const resetBoard = (step: CSTStep) => { setBoard(step.mission.map((c) => [...c])); setMoves(0); solvedRef.current = false; }; const onPointerDown = (e: React.PointerEvent, colIdx: number) => { const top = board[colIdx][board[colIdx].length - 1]; if (!top || solvedRef.current) return; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); dragRef.current = { col: colIdx, cardId: top.id, startX: e.clientX }; setDrag({ col: colIdx, dx: 0 }); }; const onPointerMove = (e: React.PointerEvent) => { if (!dragRef.current) return; setDrag({ col: dragRef.current.col, dx: e.clientX - dragRef.current.startX }); }; const onPointerUp = (e: React.PointerEvent, step: CSTStep) => { const dragInfo = dragRef.current; dragRef.current = null; setDrag(null); if (!dragInfo || solvedRef.current) return; // 드롭 위치 → 목표 열 계산: 고정 상수(COL_WIDTH) 대신 실제 렌더된 미션 열 그리드 기준으로 판정. // 기존 방식은 기기별 열 폭과 어긋나 delta=0(원위치) 또는 열 범위 밖(취소)이 되어 // "다른 위치에 놓아도 원래 자리로 돌아가는" 문제가 있었다. (수정사항-020-1) const grid = missionGridRef.current; if (!grid) return; const rect = grid.getBoundingClientRect(); if (rect.width <= 0) return; // 미션 열 영역 밖에 놓으면 취소 (원위치) if (e.clientX < rect.left || e.clientX > rect.right) return; const colW = rect.width / 3; const to = Math.max(0, Math.min(2, Math.floor((e.clientX - rect.left) / colW))); if (to === dragInfo.col) return; setBoard((prev) => { if (solvedRef.current) return prev; const next = prev.map((c) => [...c]); const top = next[dragInfo.col][next[dragInfo.col].length - 1]; if (!top || top.id !== dragInfo.cardId) return prev; next[dragInfo.col].pop(); if (next[to].length >= 4) return prev; // 최대 4장 — 5장째는 이동 취소(튕겨나옴) next[to].push(top); const solvedNow = JSON.stringify(next) === JSON.stringify(step.guide); if (solvedNow) { solvedRef.current = true; setTimeout(() => { recordRef.current({ playTime: playTimeRef.current, difficulty: step.difficulty, correct: true, movingCount: moves + 1, }); }, 120); } else { setMoves((m) => m + 1); } return next; }); }; // 브라우저가 포인터를 취소(pointercancel)하면 드래그 상태만 정리 — 원위치 처리 const onPointerCancel = () => { dragRef.current = null; setDrag(null); }; return ( s.duration)} renderStep={({ index, record, playTime }) => { const step = steps[index]; recordRef.current = record; playTimeRef.current = playTime; if (lastIndexRef.current !== index) { lastIndexRef.current = index; resetBoard(step); } return ( 목표 {step.guide.map((col, i) => ( {col.map((c, j) => ( ))} ))} 내 카드 · 드래그로 정렬 {board.map((col, i) => ( {col.map((c, j) => { const isTop = j === col.length - 1; const dragging = drag?.col === i && isTop; return ( onPointerDown(e, i) : undefined} onPointerMove={isTop ? onPointerMove : undefined} onPointerUp={isTop ? (e) => onPointerUp(e, step) : undefined} onPointerCancel={isTop ? onPointerCancel : undefined} /> ); })} ))} ); }} onTimeout={(index, record) => { const step = steps[index]; record({ playTime: step.duration, difficulty: step.difficulty, correct: false, movingCount: moves, }); }} onSubmit={async (results) => { await http.put('/api/cntdata/CST', { data: results }); }} /> ); }