/** * 얼굴표정 (EP) — 50단계(Block1 25: 사진2장 + Block2 25: 사진4장), Same/Different, 3초 (PRD §3.1.2) */ import { useMemo } from 'react'; import styled from 'styled-components'; import TestRunner from '../components/TestRunner'; import SameDiffButtons from '../components/SameDiffButtons'; import { EP_BLOCK1_SPEC, EP_BLOCK2_SPEC, EP_PEOPLE, epImage, EPEmotion, EPDifficulty, EPStepSpec, INTRO_DATA, } from '../data/tests'; import { http } from '../lib/api'; const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; /** Fisher–Yates 셔플 — PRD "블록 내 셔플" 반영 */ function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } interface EPPhoto { src: string; emotion: EPEmotion; } interface EPStep { /** null 슬롯도 랜덤 실제 이미지로 채워지므로 항상 전부 실제 사진 */ photos: EPPhoto[]; isSame: boolean; type: 'p' | 'n' | 't'; } function classify(emotions: EPEmotion[]): { isSame: boolean; type: 'p' | 'n' | 't' } { const distinct = [...new Set(emotions)]; const isSame = distinct.length === 1; let type: 'p' | 'n' | 't'; const hasNeg = distinct.some((e) => e === 'sad' || e === 'angry'); const hasPos = distinct.includes('happy'); if (isSame) { type = distinct[0] === 'happy' ? 'p' : distinct[0] === 'neutral' ? 't' : 'n'; } else if (hasPos && !hasNeg) { type = 'p'; } else if (!hasPos && hasNeg) { type = 'n'; } else { type = 't'; } return { isSame, type }; } const EP_EMOTIONS: EPEmotion[] = ['happy', 'sad', 'angry', 'neutral']; const EP_DIFFICULTIES: EPDifficulty[] = ['easy', 'normal', 'hard']; function buildStep(spec: EPStepSpec | null): EPStep { // null(단계 전체 무작위, Block1 16개) → [null,null] 2칸 = 감정·난이도 모두 랜덤 const slots: EPStepSpec = spec ?? [ [null, null], [null, null], ]; const photos: EPPhoto[] = []; const emotions: EPEmotion[] = []; // 한 단계 내 동일 인물 중복 방지 — 사용한 인물은 풀에서 제거 (다른 표정의 동일인물 중복 포함) const people = [...EP_PEOPLE]; for (const [emo, diff] of slots) { // null(감정/난이도 미지정) = 랜덤 값으로 채움 → 항상 실제 얼굴 이미지 (빈칸 없음) const emotion = emo ?? EP_EMOTIONS[rand(0, EP_EMOTIONS.length - 1)]; const difficulty = diff ?? EP_DIFFICULTIES[rand(0, EP_DIFFICULTIES.length - 1)]; const i = rand(0, people.length - 1); const person = people.splice(i, 1)[0]; // 사용한 인물은 중복 방지를 위해 풀에서 제거 photos.push({ src: epImage(person, emotion, difficulty), emotion }); emotions.push(emotion); } return { photos, ...classify(emotions) }; } const Stage = styled.div` flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; /* 사진 한 장 크기 — 2장/4장 동일하게, 가로화면에서 최대한 크게. - 너비 제약: 4장 나열이 가로폭(100vw - 셸패딩16 - 갭3*10)에 맞는 크기. 가로세로가 ~2:1인 폰 가로화면에서는 4장 나열이 사진 크기의 실질 제약. - 높이 제약: 헤더(42+8) + 힌트(~20) + 버튼(56) + 여백(셸패딩16+갭32) 제외 후 남는 높이. min()으로 둘 중 작은 쪽 사용 → 절대 하단이 잘리지 않음. */ --ep-photo: min( calc((100vw - 46px) / 4), calc(var(--test-vh, 100svh) - 190px) ); `; const Grid = styled.div<{ cols: number }>` display: grid; grid-template-columns: repeat(${(p) => p.cols}, var(--ep-photo)); gap: ${(p) => (p.cols === 4 ? 10 : 16)}px; width: 100%; justify-content: center; img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14px; background: #fff; } `; const Hint = styled.p` color: var(--pcnt-text-sub); font-size: 14px; `; export default function TestEP() { // PRD: Block1(2장)×25 + Block2(4칸)×25, 각 블록 내부는 셔플 const steps = useMemo( () => [...shuffle(EP_BLOCK1_SPEC), ...shuffle(EP_BLOCK2_SPEC)].map(buildStep), [], ); const total = steps.length; return ( { const s = steps[index]; const cols = s.photos.length; // Block1=2, Block2=4 — 항상 전부 실제 사진 return ( 모든 표정이 같은 감정인가요? {s.photos.map((p, i) => ( 표정 ))}
record({ playTime, correct: s.isSame === same, emotion: s.photos.map((p) => p.emotion), type: s.type }) } />
); }} onTimeout={(index, record) => { const s = steps[index]; record({ playTime: 3000, correct: false, emotion: s.photos.map((p) => p.emotion), type: s.type, }); }} onSubmit={async (results) => { await http.put('/api/cntdata/EP', { data: results }); }} /> ); }