diff --git a/AGENTS.md b/AGENTS.md index 317c8e4..e1e7044 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,32 +32,39 @@ AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라 ## 디렉토리 구조 및 파일 위치 규칙 ``` -app/ # Next.js App Router 루트 - (game)/ # 게임 화면 라우트 그룹 - number-parentheses/ # 숫자 괄호 준비 화면 - select-type/ # 문제 유형 선택 - select-mode/ # 게임 모드 선택 - play/ # 문제 풀이 +app/ # Next.js App Router 루트 + (game)/ # 게임 화면 라우트 그룹 + number-parentheses/ # 숫자 괄호 준비 화면 + variable-parentheses/ # 미지수 괄호 준비 화면 + play/ # 미지수 괄호 풀이 화면 + select-mode/ # 게임 모드 선택 (미구현) + play/ # 숫자 괄호 풀이 화면 api/ - auth/ # NextAuth.js 핸들러 - problem/ # 문제 생성 API - record/ # 풀이 기록 저장 API + auth/ # NextAuth.js 핸들러 + problem/ # 문제 생성 API + record/ # 풀이 기록 저장 API layout.tsx - page.tsx # 시작 화면 + page.tsx # 시작 화면 (학습 내용 선택) components/ - math/ # KaTeX 수식 컴포넌트 - game/ # 게임 UI 컴포넌트 + math/ # KaTeX 수식 컴포넌트 + game/ # 게임 UI 컴포넌트 lib/ - engine/ # 문제 생성 알고리즘 엔진 - prisma.ts # Prisma Client 싱글턴 - redis.ts # Redis Client 싱글턴 + engine/ # 문제 생성 알고리즘 엔진 + parenthesis-sign.ts # 숫자 괄호 엔진 + variable-parenthesis.ts # 미지수 괄호 엔진 + types.ts # 숫자 괄호 타입 + variable-types.ts # 미지수 괄호 타입 + input-device.ts # 입력 장치 감지 미디어 쿼리 상수 + prisma.ts # Prisma Client 싱글턴 + redis.ts # Redis Client 싱글턴 store/ - gameStore.ts # Zustand 전역 게임 상태 + gameStore.ts # 숫자 괄호 Zustand 전역 상태 + variableGameStore.ts # 미지수 괄호 Zustand 전역 상태 types/ - *.d.ts # 타입 선언이 없는 서드파티 모듈 보강 + *.d.ts # 타입 선언이 없는 서드파티 모듈 보강 prisma/ - schema.prisma # MariaDB 모델 정의 -.gitea/workflows/deploy.yml # CI/CD 워크플로우 + schema.prisma # MariaDB 모델 정의 +.gitea/workflows/deploy.yml # CI/CD 워크플로우 ``` --- @@ -78,7 +85,7 @@ prisma/ ### 상태 관리 -- 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다. +- 숫자 괄호 전역 게임 상태는 `store/gameStore.ts`, 미지수 괄호 전역 게임 상태는 `store/variableGameStore.ts`의 Zustand store로 관리한다. 학습 유형별로 store를 분리한다. - 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다. - Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다. - store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다. @@ -93,18 +100,28 @@ prisma/ ### 접근성 / 키보드 조작 - 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다. -- 문제 풀이 화면은 모든 항을 하나의 공유 input으로 입력한다. +- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다. +- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다. +- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다. + +#### 숫자 괄호 풀이 화면 (`/play`) + +- 모든 항을 하나의 공유 input으로 입력한다. - input 아래에는 각 항의 입력값 버튼을 버튼 그룹으로 표시한다. 현재 입력 중인 항은 `aria-pressed`로 표시하고, 비어 있는 항은 `?`로 표시한다. - 첫째 항에서 Enter를 누르면 같은 input이 둘째 항 입력으로 전환된다. - 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다. - 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다. -- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. +- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts`의 `DESKTOP_INPUT_DEVICE_QUERY` 사용) - 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다. - 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다. - 터치 기반 입력 환경에서 input focus 시 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다. -- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다. -- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다. -- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다. + +#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`) + +- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다. +- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동한다. +- 두 항을 모두 선택하면 `정답 확인` 버튼에 focus가 이동하고 버튼이 활성화된다. +- 선택된 보기 버튼은 `aria-pressed`로 표시한다. ### API / 백엔드 @@ -117,9 +134,14 @@ prisma/ - 문제 생성 로직은 `lib/engine/` 하위에 위치한다. - 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다. -- 미지수의 계수는 항상 정수이어야 한다. -- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(1–3), 미지수(없음/x/x,y/x,y,z). +- 계수는 항상 정수이어야 한다. - 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다. +- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다. +- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts` +- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts` + - 괄호 안 두 항 중 정확히 하나가 `정수 × x` 항이다. + - 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다. + - 채점은 choice id 기반으로 수행한다. --- @@ -147,33 +169,34 @@ NEXTAUTH_SECRET=your-secret ## 게임 도메인 컨텍스트 -### 문제 유형 +### 학습 유형 -1. **괄호 음수 분배**: 괄호 앞 음수 곱셈 시 괄호 내 부호 변경 (예: `-2 × (3 - 6)`) -2. **이항 부호 변경**: 등식의 항을 이항할 때 부호 반전 (예: `3x - 5y + 8 = -2x + 2y - 2`) +| 유형 | 상태 | 라우트 | 답변 방식 | +|---|---|---|---| +| 숫자 괄호 풀기 | 구현 완료 | `/number-parentheses` → `/play` | 직접 입력 | +| 미지수 괄호 풀기 | 구현 완료 | `/variable-parentheses` → `/variable-parentheses/play` | 보기 선택 | +| 분수 괄호 풀기 | 미구현 | — | — | +| 실수 괄호 풀기 | 미구현 | — | — | ### 게임 모드 | 모드 | 설명 | |---|---| -| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 | -| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 | +| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 (미구현) | +| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 (미구현) | ### 화면 흐름 ``` -학습 내용 선택 → 숫자 괄호 준비 → 문제 풀이 → 결과 및 기록 +학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과 ``` -- 시작 화면(`/`)은 학습 내용 선택 화면이다. -- 현재 활성 학습 내용은 `숫자 괄호 풀기`이며 `/number-parentheses`로 이동한다. -- `분수 괄호 풀기`, `실수 괄호 풀기`, `미지수 괄호 풀기`는 준비 중 항목으로 disabled 상태로 표시한다. -- 숫자 괄호 준비 화면(`/number-parentheses`)은 문제 수 `[5] [10]` 선택 버튼 그룹을 제공하고 기본값은 `10`이다. -- 숫자 괄호 준비 화면은 괄호 종류 `()`, `{}`, `[]` 버튼 그룹을 제공한다. 현재는 `()`만 선택 가능하고 `{}`, `[]`는 disabled 상태로 표시한다. -- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 `/play?count=N`으로 이동한다. -- 문제 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. -- 정답·오답 피드백은 `계산식 = 내가 입력한 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 입력값과 `×`, 굵은 정답값을 함께 보여준다. -- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다. +- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다. +- 준비 화면은 문제 수 `[5] [10]` 버튼 그룹과 괄호 종류 버튼 그룹을 제공한다. 기본값은 `10`이며, 현재는 `()`만 선택 가능하다. +- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다. +- 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. +- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다. +- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다. --- diff --git a/README.md b/README.md index 5a8d862..4ea7c5b 100644 --- a/README.md +++ b/README.md @@ -24,55 +24,51 @@ --- -## 문제 유형 +## 학습 유형 -1. **괄호 음수 분배** — 괄호 앞 음수 곱셈 시 괄호 내 부호 변경 - - 예: `-2 × (3 - 6)` -2. **이항 부호 변경** — 등식의 항을 이항할 때 부호 반전 - - 예: `3x - 5y + 8 = -2x + 2y - 2` - -## 게임 모드 - -| 모드 | 설명 | -|---|---| -| 스피드 퀴즈 | 제한 시간 내 최대 문제 풀기, Redis Sorted Set 기반 실시간 점수판 | -| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 | +| 유형 | 상태 | 답변 방식 | +|---|---|---| +| 숫자 괄호 풀기 | ✅ 구현 완료 | 직접 입력 | +| 미지수 괄호 풀기 | ✅ 구현 완료 | 보기 선택 | +| 분수 괄호 풀기 | 준비 중 | — | +| 실수 괄호 풀기 | 준비 중 | — | ## 화면 흐름 ``` -학습 내용 선택 → 숫자 괄호 준비 → 문제 풀이 → 결과 및 기록 +학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과 ``` --- ## 현재 구현 상태 -- 시작 화면(`/`)에서 학습 내용을 선택합니다. - - `숫자 괄호 풀기`는 활성 상태이며 `/number-parentheses` 준비 화면으로 이동합니다. - - `분수 괄호 풀기`, `실수 괄호 풀기`, `미지수 괄호 풀기`는 준비 중 항목으로 비활성화되어 있습니다. -- 숫자 괄호 준비 화면(`/number-parentheses`)에서 문제 수를 5개 또는 10개로 선택한 뒤 `/play` 풀이 화면으로 이동할 수 있습니다. -- 문제 수 기본값은 10개이며, `/play?count=5` 또는 `/play?count=10`으로 선택값을 전달합니다. -- 숫자 괄호 준비 화면의 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다. +### 공통 + +- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다. - 문제와 정답 수식은 KaTeX로 렌더링합니다. - 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다. -- 정답·오답 피드백은 각 항의 `계산식 = 내가 입력한 답` 형식으로 표시합니다. - - 정답 항목은 정답 색상 박스와 `○` 표시를 사용합니다. - - 오답 항목은 오답 색상 박스, 붉은색 입력값, `×`, 굵은 정답값을 함께 표시합니다. -- 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한 답` 형식으로 다시 보여주고, 정답·오답 표시 규칙을 피드백 화면과 공유합니다. -- 키보드 흐름을 지원합니다. - - 모든 항은 하나의 input에서 순서대로 입력 - - 첫째 항 Enter → 같은 input이 둘째 항 입력으로 전환 - - 둘째 항 Enter → 제출 - - 피드백 화면 → 다음 문제/결과 보기 버튼에 focus - - 다음 문제 이동 후 → input에 focus - - 결과 화면 → 다시 시작 버튼에 focus -- input 아래에는 각 항의 입력값 버튼이 표시됩니다. 비어 있는 항은 `?`로 표시하고, 버튼을 누르면 해당 항을 초기화한 뒤 다시 입력합니다. -- 터치 기반 입력 환경에서는 input focus 후 커스텀 숫자 키패드를 표시합니다. - - 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공합니다. - - `↵`는 첫째 항에서 둘째 항으로 이동하고, 둘째 항에서 제출합니다. - - 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주해 키패드를 숨깁니다. -- 입력 중에는 `-`, `1.` 같은 임시 값을 허용하지만, 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과합니다. +- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다. + - 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다. +- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다. +- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다. + +### 숫자 괄호 풀기 (`/number-parentheses` → `/play`) + +- 준비 화면에서 문제 수 5 / 10을 선택하고 연습을 시작합니다. 기본값은 10. +- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다. +- 풀이 화면에서 두 항을 직접 숫자로 입력합니다. + - 모든 항은 하나의 input에서 순서대로 입력하며, 첫째 항 Enter → 둘째 항 전환, 둘째 항 Enter → 제출. + - input 아래 항 버튼 그룹에서 현재 입력 중인 항을 표시하고, 버튼을 누르면 해당 항을 초기화해 다시 입력합니다. + - 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다. + +### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`) + +- 준비 화면 구성은 숫자 괄호와 동일합니다. +- 풀이 화면에서 두 항을 보기 중에서 선택합니다. + - 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다. + - 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동합니다. + - 두 항 선택 완료 후 `정답 확인` 버튼이 활성화되며 버튼에 focus가 이동합니다. --- @@ -126,38 +122,43 @@ pnpm start ## 프로젝트 구조 ``` -app/ # Next.js App Router 루트 - (game)/ # 게임 화면 라우트 그룹 - number-parentheses/ # 숫자 괄호 준비 화면 - select-type/ # 문제 유형 선택 - select-mode/ # 게임 모드 선택 - play/ # 문제 풀이 +app/ # Next.js App Router 루트 + (game)/ # 게임 화면 라우트 그룹 + number-parentheses/ # 숫자 괄호 준비 화면 + variable-parentheses/ # 미지수 괄호 준비 화면 + play/ # 미지수 괄호 풀이 화면 + play/ # 숫자 괄호 풀이 화면 api/ - auth/ # NextAuth.js 핸들러 - problem/ # 문제 생성 API - record/ # 풀이 기록 저장 API + auth/ # NextAuth.js 핸들러 + problem/ # 문제 생성 API + record/ # 풀이 기록 저장 API layout.tsx - page.tsx # 시작 화면 + page.tsx # 시작 화면 (학습 내용 선택) components/ - math/ # KaTeX 수식 컴포넌트 - game/ # 게임 UI 컴포넌트, 피드백/결과 공통 표시 컴포넌트 + math/ # KaTeX 수식 컴포넌트 + game/ # 게임 UI 컴포넌트 lib/ - engine/ # 문제 생성 알고리즘 엔진 - prisma.ts # Prisma Client 싱글턴 - redis.ts # Redis Client 싱글턴 + engine/ # 문제 생성 알고리즘 엔진 + parenthesis-sign.ts # 숫자 괄호 엔진 + variable-parenthesis.ts # 미지수 괄호 엔진 + input-device.ts # 입력 장치 감지 미디어 쿼리 + prisma.ts # Prisma Client 싱글턴 + redis.ts # Redis Client 싱글턴 store/ - gameStore.ts # Zustand 전역 게임 상태 + gameStore.ts # 숫자 괄호 Zustand 전역 상태 + variableGameStore.ts # 미지수 괄호 Zustand 전역 상태 prisma/ - schema.prisma # MariaDB 모델 정의 -.gitea/workflows/deploy.yml # CI/CD 워크플로우 + schema.prisma # MariaDB 모델 정의 +.gitea/workflows/deploy.yml # CI/CD 워크플로우 ``` --- ## 다음 확장 방향 -- 분수 괄호, 실수 괄호, 미지수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다. -- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 숫자 괄호 준비 화면에서 활성화합니다. +- 분수 괄호, 실수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다. +- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 준비 화면에서 활성화합니다. +- 스피드 퀴즈 모드, 풀이 기록 저장, 로그인 기능은 DB/Redis/NextAuth 연동 후 추가합니다. --- diff --git a/app/(game)/variable-parentheses/VariableParenthesesClient.tsx b/app/(game)/variable-parentheses/VariableParenthesesClient.tsx new file mode 100644 index 0000000..2b36bbb --- /dev/null +++ b/app/(game)/variable-parentheses/VariableParenthesesClient.tsx @@ -0,0 +1,146 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef } from "react"; + +import { + useVariableGameStore, + type VariableProblemCount, +} from "@/store/variableGameStore"; + +const problemCounts: VariableProblemCount[] = [5, 10]; + +const bracketKinds = [ + { + label: "()", + name: "소괄호", + selected: true, + disabled: false, + }, + { + label: "{}", + name: "중괄호", + selected: false, + disabled: true, + }, + { + label: "[]", + name: "대괄호", + selected: false, + disabled: true, + }, +]; + +export default function VariableParenthesesClient() { + const startLinkRef = useRef(null); + const selectedProblemCount = useVariableGameStore( + (state) => state.selectedProblemCount, + ); + const setProblemCount = useVariableGameStore( + (state) => state.setProblemCount, + ); + const initSession = useVariableGameStore((state) => state.initSession); + + useEffect(() => { + startLinkRef.current?.focus(); + }, []); + + return ( +
+
+
+

+ 미지수 괄호 학습 준비 +

+

+ 미지수 괄호 풀기 +

+

+ 괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 + 연습합니다. +

+
+ +
+
+ + 문제 수 + +
+ {problemCounts.map((count) => { + const selected = selectedProblemCount === count; + + return ( + + ); + })} +
+
+ +
+ + 괄호 종류 + +
+ {bracketKinds.map((bracketKind) => ( + + ))} +
+
+ +
+ initSession(selectedProblemCount)} + className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2" + > + 연습 시작 + +
+
+ +
+

현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다.

+
+
+
+ ); +} diff --git a/app/(game)/variable-parentheses/page.tsx b/app/(game)/variable-parentheses/page.tsx new file mode 100644 index 0000000..dd9fd7a --- /dev/null +++ b/app/(game)/variable-parentheses/page.tsx @@ -0,0 +1,5 @@ +import VariableParenthesesClient from "./VariableParenthesesClient"; + +export default function VariableParenthesesPage() { + return ; +} diff --git a/app/(game)/variable-parentheses/play/VariablePlayClient.tsx b/app/(game)/variable-parentheses/play/VariablePlayClient.tsx new file mode 100644 index 0000000..0858977 --- /dev/null +++ b/app/(game)/variable-parentheses/play/VariablePlayClient.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { flushSync } from "react-dom"; + +import StageIndicator from "@/components/game/StageIndicator"; +import VariableChoiceAnswerForm from "@/components/game/VariableChoiceAnswerForm"; +import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel"; +import VariableScoreSummary from "@/components/game/VariableScoreSummary"; +import KatexRenderer from "@/components/math/KatexRenderer"; +import { + useVariableGameStore, + type VariableProblemCount, +} from "@/store/variableGameStore"; + +interface VariablePlayClientProps { + problemCount: VariableProblemCount; +} + +export default function VariablePlayClient({ + problemCount, +}: VariablePlayClientProps) { + const session = useVariableGameStore((state) => state.session); + const selectedProblemCount = useVariableGameStore( + (state) => state.selectedProblemCount, + ); + const setProblemCount = useVariableGameStore( + (state) => state.setProblemCount, + ); + const initSession = useVariableGameStore((state) => state.initSession); + const submitAnswer = useVariableGameStore((state) => state.submitAnswer); + const nextProblem = useVariableGameStore((state) => state.nextProblem); + const resetSession = useVariableGameStore((state) => state.resetSession); + + const [selectedChoiceIds, setSelectedChoiceIds] = useState< + [string | null, string | null] + >([null, null]); + const [activeTermIndex, setActiveTermIndex] = useState<0 | 1>(0); + + const sessionProblemCount = session?.problems.length ?? null; + const currentProblem = session?.problems[session.currentIndex] ?? null; + const currentResult = session?.results[session.currentIndex] ?? null; + const isComplete = Boolean( + session && session.currentIndex >= session.problems.length, + ); + const sessionNeedsInit = sessionProblemCount !== problemCount; + const correctCount = + session?.results.filter((result) => result?.correct).length ?? 0; + const totalCount = session?.problems.length ?? 0; + + useEffect(() => { + if (selectedProblemCount !== problemCount) { + setProblemCount(problemCount); + } + + if (sessionNeedsInit) { + initSession(problemCount); + } + }, [ + initSession, + problemCount, + selectedProblemCount, + sessionProblemCount, + sessionNeedsInit, + setProblemCount, + ]); + + useEffect(() => { + setSelectedChoiceIds([null, null]); + setActiveTermIndex(0); + }, [currentProblem?.key]); + + function handleSelectChoice(termIndex: 0 | 1, choiceId: string) { + setSelectedChoiceIds((current) => { + const next: [string | null, string | null] = [...current]; + next[termIndex] = choiceId; + return next; + }); + + if (termIndex === 0) { + setActiveTermIndex(1); + } + } + + function handleSubmit() { + if (currentResult || !currentProblem) return; + if (selectedChoiceIds[0] === null || selectedChoiceIds[1] === null) return; + + submitAnswer({ selectedChoiceIds }); + } + + function handleNextProblem() { + flushSync(() => { + nextProblem(); + }); + } + + if (!session || sessionNeedsInit) { + return ( +
+
+

+ 문제를 준비하고 있습니다. +

+
+
+ ); + } + + if (isComplete) { + return ( + + ); + } + + if (!currentProblem) { + return ( +
+
+

+ 현재 문제를 불러올 수 없습니다. +

+
+
+ ); + } + + const submitted = currentResult !== null; + const isLastProblem = session.currentIndex === session.problems.length - 1; + + return ( +
+
+
+
+

+ 미지수 괄호 풀기 +

+

+ 문제 풀이 +

+
+ +
+ +
+

+ 괄호를 풀어 첫째 항과 둘째 항의 답을 보기에서 고르세요. +

+
+ +
+
+ + {!submitted ? ( + + ) : null} + + {currentResult ? ( + + ) : null} +
+
+ ); +} diff --git a/app/(game)/variable-parentheses/play/page.tsx b/app/(game)/variable-parentheses/play/page.tsx new file mode 100644 index 0000000..7e1bf6d --- /dev/null +++ b/app/(game)/variable-parentheses/play/page.tsx @@ -0,0 +1,24 @@ +import VariablePlayClient from "./VariablePlayClient"; +import type { VariableProblemCount } from "@/store/variableGameStore"; + +interface VariablePlayPageProps { + searchParams?: { + count?: string | string[]; + }; +} + +function parseProblemCount(count?: string | string[]): VariableProblemCount { + if (Array.isArray(count)) { + return parseProblemCount(count[0]); + } + + return count === "5" ? 5 : 10; +} + +export default function VariablePlayPage({ + searchParams, +}: VariablePlayPageProps) { + return ( + + ); +} diff --git a/app/page.tsx b/app/page.tsx index ce8c793..d7b2d41 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -32,8 +32,9 @@ const subjects: Subject[] = [ }, { label: "미지수 괄호 풀기", - description: "미지수가 들어간 괄호식은 이후 단계에서 추가됩니다.", - disabled: true, + description: "괄호 안에 미지수 x가 들어간 식의 부호 변화를 연습합니다.", + href: "/variable-parentheses", + disabled: false, }, ]; @@ -90,7 +91,7 @@ export default function Home() {
-

지금은 숫자 괄호 학습만 사용할 수 있습니다.

+

지금은 숫자 괄호와 미지수 괄호 학습을 사용할 수 있습니다.

diff --git a/components/game/StageIndicator.tsx b/components/game/StageIndicator.tsx index 76a3f0f..9de4068 100644 --- a/components/game/StageIndicator.tsx +++ b/components/game/StageIndicator.tsx @@ -1,10 +1,10 @@ -"use client"; - -import type { GradeResult } from "@/lib/engine/types"; +interface StageResult { + correct: boolean; +} interface StageIndicatorProps { currentIndex: number; - results: (GradeResult | null)[]; + results: (StageResult | null)[]; } export default function StageIndicator({ diff --git a/components/game/VariableChoiceAnswerForm.tsx b/components/game/VariableChoiceAnswerForm.tsx new file mode 100644 index 0000000..2a9c832 --- /dev/null +++ b/components/game/VariableChoiceAnswerForm.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import KatexRenderer from "@/components/math/KatexRenderer"; +import type { VariableTermChoice } from "@/lib/engine/variable-types"; + +interface VariableChoiceAnswerFormProps { + choices: [VariableTermChoice[], VariableTermChoice[]]; + selectedChoiceIds: [string | null, string | null]; + activeTermIndex: 0 | 1; + onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void; + onSubmit: () => void; +} + +export default function VariableChoiceAnswerForm({ + choices, + selectedChoiceIds, + activeTermIndex, + onSelectChoice, + onSubmit, +}: VariableChoiceAnswerFormProps) { + const firstChoiceRef = useRef(null); + const submitButtonRef = useRef(null); + const canSubmit = selectedChoiceIds[0] !== null && selectedChoiceIds[1] !== null; + const activeChoices = choices[activeTermIndex]; + + useEffect(() => { + firstChoiceRef.current?.focus(); + }, [activeTermIndex]); + + useEffect(() => { + if (canSubmit) { + submitButtonRef.current?.focus(); + } + }, [canSubmit]); + + return ( +
+
+
+

+ {activeTermIndex === 0 ? "첫째 항" : "둘째 항"}을 고르세요. +

+

+ 괄호를 풀었을 때의 부호를 선택합니다. +

+
+
+ {selectedChoiceIds.map((choiceId, index) => { + const selected = activeTermIndex === index; + + return ( + + {choiceId ? `${index + 1}항 완료` : `${index + 1}항`} + + ); + })} +
+
+ +
+ {activeChoices.map((choice, index) => { + const selected = selectedChoiceIds[activeTermIndex] === choice.id; + + return ( + + ); + })} +
+ + +
+ ); +} diff --git a/components/game/VariableFeedbackPanel.tsx b/components/game/VariableFeedbackPanel.tsx new file mode 100644 index 0000000..780d14b --- /dev/null +++ b/components/game/VariableFeedbackPanel.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import AnswerResultLine from "@/components/game/AnswerResultLine"; +import type { + VariableGradeResult, + VariableParenthesisProblem, + VariableParenthesisTerm, +} from "@/lib/engine/variable-types"; +import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis"; + +interface VariableFeedbackPanelProps { + result: VariableGradeResult; + problem: VariableParenthesisProblem; + isLastProblem: boolean; + onNext: () => void; +} + +function formatSignedNumber(value: number) { + return value < 0 ? `(${value})` : `${value}`; +} + +function formatSignedTerm(term: VariableParenthesisTerm, sign: 1 | -1) { + const coefficient = term.coefficient * sign; + + return formatVariableTermLatex({ ...term, coefficient }); +} + +export default function VariableFeedbackPanel({ + result, + problem, + isLastProblem, + onNext, +}: VariableFeedbackPanelProps) { + const nextButtonRef = useRef(null); + const signedTerms = [ + formatSignedTerm(problem.firstTerm, 1), + formatSignedTerm(problem.secondTerm, problem.operator === "-" ? -1 : 1), + ] as const; + const feedbackItems = signedTerms.map((termLatex, index) => { + const termIndex = index as 0 | 1; + const userAnswer = result.userAnswer[termIndex]; + const correctAnswer = result.correctAnswer[termIndex]; + + return { + formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`, + userAnswerLatex: userAnswer?.latex ?? "?", + correctAnswerLatex: correctAnswer.latex, + correct: userAnswer?.coefficient === correctAnswer.coefficient, + }; + }); + + useEffect(() => { + nextButtonRef.current?.focus(); + }, []); + + return ( +
+

+ {result.correct ? "정답입니다." : "오답입니다."} +

+
    + {feedbackItems.map((item, index) => ( + + ))} +
+ +
+ ); +} diff --git a/components/game/VariableScoreSummary.tsx b/components/game/VariableScoreSummary.tsx new file mode 100644 index 0000000..abc212f --- /dev/null +++ b/components/game/VariableScoreSummary.tsx @@ -0,0 +1,157 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef } from "react"; + +import AnswerResultLine from "@/components/game/AnswerResultLine"; +import KatexRenderer from "@/components/math/KatexRenderer"; +import type { + DistributedVariableTerm, + VariableGradeResult, + VariableParenthesisProblem, +} from "@/lib/engine/variable-types"; +import { formatVariableExpressionLatex } from "@/lib/engine/variable-parenthesis"; + +interface VariableScoreSummaryProps { + correctCount: number; + totalCount: number; + problems: VariableParenthesisProblem[]; + results: (VariableGradeResult | null)[]; + onRestart: () => void; +} + +function renderTermPair( + terms: [DistributedVariableTerm | null, DistributedVariableTerm | null], + comparisonTerms: [DistributedVariableTerm | null, DistributedVariableTerm | null], + mode: "user" | "correct", +) { + const secondTerm = terms[1]; + const secondOperator = + secondTerm && secondTerm.coefficient < 0 ? "-" : "+"; + + return ( + + {terms.map((term, index) => { + if (!term) { + return ( + + ? + + ); + } + + const correct = term.coefficient === comparisonTerms[index]?.coefficient; + const className = + mode === "user" + ? correct + ? undefined + : "text-red-700" + : correct + ? undefined + : "font-bold text-blue-700"; + const displayLatex = + index === 1 + ? `${secondOperator} ${term.latex.replace(/^-/, "")}` + : term.latex; + + return ( + + + + ); + })} + + ); +} + +export default function VariableScoreSummary({ + correctCount, + totalCount, + problems, + results, + onRestart, +}: VariableScoreSummaryProps) { + const restartButtonRef = useRef(null); + const solvedItems = problems + .map((problem, index) => ({ + problem, + result: results[index], + })) + .filter( + ( + item, + ): item is { + problem: VariableParenthesisProblem; + result: VariableGradeResult; + } => item.result !== null, + ); + + useEffect(() => { + restartButtonRef.current?.focus(); + }, []); + + return ( +
+
+
+

+ 미지수 괄호 풀기 +

+

+ 풀이 결과 +

+

+ {correctCount} / {totalCount} 정답 +

+
+ +
    + {solvedItems.map(({ problem, result }, index) => ( + + {index + 1}. + + } + /> + ))} +
+ +
+ + + 처음으로 + +
+
+
+ ); +} diff --git a/docs/vibe/04-subject-variable.md b/docs/vibe/04-subject-variable.md new file mode 100644 index 0000000..d175a61 --- /dev/null +++ b/docs/vibe/04-subject-variable.md @@ -0,0 +1,413 @@ +# 04. 미지수 괄호 학습과 선택지형 풀이 화면 + +## 계획 검토 + +Codex가 작성한 초안은 방향이 맞다. 엔진 설계, store 분리, 화면 구조가 기존 숫자 괄호 흐름과 일관성 있게 정리되어 있다. + +조정이 필요한 부분: + +- `VariableUserAnswer`의 `selectedChoiceIds`는 보기 id 기반 채점이므로 그대로 유지한다. 보기가 매번 섞이므로 계수 값 자체보다 id로 선택을 추적하는 것이 명확하다. +- 사용자가 요청한 "보기 하나는 음수, 보기 하나는 양수" 방식이 Codex 초안과 동일하다. 보기는 정답 계수의 절대값을 기준으로 양수/음수 두 개만 표시한다. +- 선택지 순서는 매번 셔플한다. +- 둘째 항 선택 후 `정답 확인` 버튼으로 명시적으로 제출한다. 자동 제출은 사용자가 선택을 확인할 여지 없이 넘어가므로 사용하지 않는다. +- 준비 화면은 `NumberParenthesesClient`와 구조가 동일하므로 처음에는 별도 파일로 구현한다. 중복이 불편해지면 그때 공통 컴포넌트로 분리한다. + +--- + +## 목표 + +미지수가 포함된 괄호 풀기 문제를 선택지 방식으로 풀 수 있는 학습 흐름을 추가한다. + +이번 단계의 최종 상태: + +- `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다. +- `/variable-parentheses` 준비 화면에서 문제 수와 괄호 종류를 선택한다. +- `/variable-parentheses/play?count=N` 풀이 화면에서 첫째 항, 둘째 항 순서로 보기를 고르고 정답을 확인한다. +- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다. + +--- + +## 이번 단계에서 할 것 + +- 첫 화면의 `미지수 괄호 풀기` disabled 해제 (`/variable-parentheses` 링크) +- 미지수 괄호 학습 준비 화면 추가 +- 미지수 괄호 문제 생성 엔진 추가 +- 미지수 괄호 전용 Zustand store 추가 +- 미지수 괄호 전용 풀이 화면 추가 (선택지형) +- 정답·오답 피드백 및 결과 화면 추가 +- 엔진 단위 테스트 추가 + +## 이번 단계에서 하지 않을 것 + +- 분수 괄호, 실수 괄호 문제 생성 +- `{}`, `[]` 괄호 문제 생성 +- 여러 미지수(`x, y, z`) 지원 +- 기존 숫자 괄호 `/play` 화면 동작 변경 +- 사용자가 직접 미지수 답을 타이핑하는 입력 방식 +- DB, Redis, NextAuth 연동 +- 스피드 퀴즈 모드 + +--- + +## 문제 생성 규칙 + +### 문제 형태 + +```text +multiplier(a ± b) +``` + +괄호 안 두 항 중 정확히 하나가 `정수 × x` 항이다. + +예시: + +```text +-2(3x - 6) → -6x + 12 +-5(4 + 7x) → -20 - 35x +-3(8x + 2) → -24x - 6 +``` + +### 생성 조건 + +| 항목 | 범위 | +|---|---| +| `multiplier` | `-9`~`-1` 정수 | +| 숫자 항 계수 | `1`~`9` 정수 | +| 미지수 항 계수 | `1`~`9` 정수 | +| 미지수 | `x` 고정 | +| 연산자 | `+` 또는 `-` | +| 미지수 항 위치 | 첫째 항 또는 둘째 항 (한 문제에 하나만) | + +두 항 계수가 같아도 허용한다 (숫자 괄호와 달리 항의 종류가 다르므로 혼동이 없다). + +### 정답 계산 + +- 첫째 항 정답: `multiplier × firstTerm.coefficient` (미지수 항이면 `x` 붙임) +- 둘째 항 정답: `multiplier × (operator === '-' ? -secondTerm.coefficient : secondTerm.coefficient)` (미지수 항이면 `x` 붙임) + +### 보기 생성 + +정답 계수의 절대값을 기준으로 두 보기를 만든다. + +```text +정답: -6x → 보기: -6x, 6x +정답: 12 → 보기: -12, 12 +``` + +- 보기 두 개는 항 종류(미지수 항 / 숫자 항)가 같다. +- 보기 순서는 매번 셔플한다. +- 보기 `id`는 안정적으로 생성한다: `${problem.key}:term-0:negative`, `${problem.key}:term-0:positive` + +### 중복 방지 key + +```text +${multiplier}:${firstKind}:${firstCoeff}:${operator}:${secondKind}:${secondCoeff} +``` + +예: `-2:variable:3:-:constant:6` + +--- + +## 타입 설계 + +신규 파일: `lib/engine/variable-types.ts` + +```ts +export type VariableTermKind = 'constant' | 'variable' + +export interface VariableParenthesisTerm { + kind: VariableTermKind + coefficient: number +} + +export interface DistributedVariableTerm { + kind: VariableTermKind + coefficient: number + latex: string +} + +export interface VariableTermChoice { + id: string + coefficient: number + kind: VariableTermKind + latex: string +} + +export interface VariableParenthesisProblem { + id: string + key: string + multiplier: number + operator: '+' | '-' + firstTerm: VariableParenthesisTerm + secondTerm: VariableParenthesisTerm + correctTerms: [DistributedVariableTerm, DistributedVariableTerm] + choices: [VariableTermChoice[], VariableTermChoice[]] + latexBefore: string + latexAfter: string +} + +export interface VariableUserAnswer { + selectedChoiceIds: [string, string] +} + +export interface VariableGradeResult { + correct: boolean + firstCorrect: boolean + secondCorrect: boolean + userChoiceIds: [string, string] + correctTerms: [DistributedVariableTerm, DistributedVariableTerm] +} +``` + +--- + +## 엔진 설계 + +신규 파일: `lib/engine/variable-parenthesis.ts` + +구현 함수: + +- `generateVariableParenthesisSession(count?: number): VariableParenthesisProblem[]` +- `gradeVariableParenthesisAnswer(problem, answer): VariableGradeResult` + +내부 헬퍼: + +- `formatVariableTermLatex(kind, coefficient): string` + - `kind === 'variable'`: `6x`, `-6x` + - `kind === 'constant'`: `6`, `-6` +- `makeChoices(termIndex, key, distributedTerm): VariableTermChoice[]` + - 양수/음수 두 보기를 만들고 셔플 +- `createAllVariableSeeds()`: 전체 가능 시드 목록 (최초 1회 생성) +- `shuffle()`: 기존 숫자 괄호 엔진과 동일한 방식 + +세션 생성 방식은 기존 숫자 괄호 엔진과 동일하게 전체 시드 셔플 후 슬라이스로 중복 방지한다. + +--- + +## 상태 관리 설계 + +신규 파일: `store/variableGameStore.ts` + +기존 `gameStore.ts`와 동일한 구조로 분리한다. + +```ts +interface VariableGameState { + session: VariableGameSession | null + selectedProblemCount: ProblemCount + setProblemCount: (count: ProblemCount) => void + initSession: (count?: ProblemCount) => void + submitAnswer: (answer: VariableUserAnswer) => void + nextProblem: () => void + resetSession: () => void +} +``` + +주의: + +- selector 안에서 새 객체·배열을 반환하지 않는다. +- 현재 선택 중인 보기 상태(`selectedChoiceIds`, `activeTermIndex`)는 `VariablePlayClient` 로컬 state로 관리한다. + +--- + +## 화면 설계 + +### 첫 화면 (`app/page.tsx`) + +- `미지수 괄호 풀기` disabled 해제, `href="/variable-parentheses"` 추가 +- 분수 괄호, 실수 괄호는 계속 disabled 유지 +- 안내 문구를 `숫자 괄호`, `미지수 괄호` 두 개가 활성화된 상태로 수정 + +### 미지수 괄호 학습 준비 화면 + +신규 파일: + +```text +app/(game)/variable-parentheses/page.tsx +app/(game)/variable-parentheses/VariableParenthesesClient.tsx +``` + +화면 구성: + +- 문제 수: `5`, `10` 버튼 그룹 (기본값 `10`) +- 괄호 종류: `()` selected, `{}` disabled, `[]` disabled +- `연습 시작` 버튼 → `/variable-parentheses/play?count=${selectedProblemCount}` 이동, 클릭 시 세션 초기화 + +### 미지수 괄호 풀이 화면 + +신규 파일: + +```text +app/(game)/variable-parentheses/play/page.tsx +app/(game)/variable-parentheses/play/VariablePlayClient.tsx +components/game/VariableChoiceAnswerForm.tsx +components/game/VariableFeedbackPanel.tsx +components/game/VariableScoreSummary.tsx +``` + +풀이 흐름: + +```text +문제 표시 (KaTeX 카드) + ↓ +첫째 항 보기 2개 표시 → 선택 + ↓ +둘째 항 보기 2개 표시 → 선택 + ↓ +[정답 확인] 버튼 활성화 → 클릭 + ↓ +피드백 표시 → [다음 문제] 또는 [결과 보기] + ↓ +최종 결과 화면 +``` + +`VariableChoiceAnswerForm` props 초안: + +```ts +interface VariableChoiceAnswerFormProps { + choices: [VariableTermChoice[], VariableTermChoice[]] + selectedChoiceIds: [string | null, string | null] + activeTermIndex: 0 | 1 + onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void + onSubmit: () => void +} +``` + +접근성: + +- 첫째 항 선택 후 둘째 항 첫 번째 보기 버튼에 focus 이동 +- 두 항 모두 선택되면 `정답 확인` 버튼 활성화 +- 피드백 표시 시 `다음 문제` 버튼에 focus 이동 +- 결과 화면 표시 시 `다시 시작` 버튼에 focus 이동 + +`VariableFeedbackPanel` 표시 형식: + +```text +(-2) × 3x = -6x ○ +(-2) × (-6) = 12 ○ +``` + +`VariableScoreSummary` 표시 형식: + +```text +-2(3x - 6) = -6x + 12 ○ +``` + +기존 `AnswerResultLine` 컴포넌트를 재사용한다. 항 표현이 복잡해지는 경우 `userAnswerContent` / `correctAnswerContent` prop으로 커스텀 렌더링한다. + +--- + +## 작업 순서 + +### 4-A. 미지수 괄호 엔진 추가 + +완료 기준: `pnpm test` 통과 + +- `lib/engine/variable-types.ts` 타입 정의 +- `lib/engine/variable-parenthesis.ts` 엔진 구현 +- `lib/engine/variable-parenthesis.test.ts` 단위 테스트 + +테스트 항목: + +- 괄호 안 두 항 중 정확히 하나만 미지수 항이다 +- 계수는 모두 정수다 +- 정답 분배 계산이 맞다 +- 둘째 항이 `-` 뒤에 있을 때 부호가 올바르게 뒤집힌다 +- 각 항의 보기는 정답 절대값 기준 양수/음수 두 개다 +- choice id 기준으로 채점이 올바르다 +- 세션 문제 key가 중복되지 않는다 +- 기존 숫자 괄호 엔진 테스트가 회귀 없이 통과한다 + +### 4-B. 미지수 괄호 store 추가 + +완료 기준: 세션 생성 및 진행 가능 + +- `store/variableGameStore.ts` + +### 4-C. 준비 화면 추가 + +완료 기준: `/variable-parentheses` 접속 시 준비 화면이 표시된다 + +- `app/(game)/variable-parentheses/page.tsx` +- `app/(game)/variable-parentheses/VariableParenthesesClient.tsx` + +### 4-D. 첫 화면 수정 + +완료 기준: `/`에서 `미지수 괄호 풀기`가 활성 링크로 표시된다 + +- `app/page.tsx` 수정 + +### 4-E. 선택지형 풀이 화면 추가 + +완료 기준: `/variable-parentheses/play?count=5`에서 문제를 풀 수 있다 + +- `app/(game)/variable-parentheses/play/page.tsx` +- `app/(game)/variable-parentheses/play/VariablePlayClient.tsx` +- `components/game/VariableChoiceAnswerForm.tsx` + +### 4-F. 피드백 및 결과 화면 추가 + +완료 기준: 선택 결과에 따라 피드백과 최종 결과가 표시된다 + +- `components/game/VariableFeedbackPanel.tsx` +- `components/game/VariableScoreSummary.tsx` + +### 4-G. 회귀 확인 + +완료 기준: 숫자 괄호와 미지수 괄호 흐름이 모두 동작한다 + +1. `/` → `숫자 괄호 풀기` → 준비 화면 → `/play?count=5` → 풀이 → 결과 +2. `/` → `미지수 괄호 풀기` → 준비 화면 → `/variable-parentheses/play?count=5` → 풀이 → 결과 + +--- + +## 생성 또는 수정 파일 + +### 신규 생성 + +```text +lib/engine/variable-types.ts +lib/engine/variable-parenthesis.ts +lib/engine/variable-parenthesis.test.ts +store/variableGameStore.ts +app/(game)/variable-parentheses/page.tsx +app/(game)/variable-parentheses/VariableParenthesesClient.tsx +app/(game)/variable-parentheses/play/page.tsx +app/(game)/variable-parentheses/play/VariablePlayClient.tsx +components/game/VariableChoiceAnswerForm.tsx +components/game/VariableFeedbackPanel.tsx +components/game/VariableScoreSummary.tsx +``` + +### 수정 + +```text +app/page.tsx +``` + +### 변경 없음 + +```text +app/(game)/play/page.tsx +app/(game)/play/PlayClient.tsx +store/gameStore.ts +lib/engine/parenthesis-sign.ts +lib/engine/parenthesis-sign.test.ts +``` + +--- + +## 완료 기준 + +- `/`에서 `미지수 괄호 풀기`가 활성화되어 있다 +- `/variable-parentheses` 준비 화면에서 문제 수 `5`, `10`을 선택할 수 있다 +- `()`가 selected 상태이고 `{}`, `[]`는 disabled 상태다 +- `연습 시작`으로 `/variable-parentheses/play?count=N`에 진입한다 +- 미지수 괄호 문제는 괄호 안 두 항 중 하나만 `정수x` 항을 포함한다 +- 풀이 화면에서 첫째 항, 둘째 항을 순서대로 보기에서 선택할 수 있다 +- 각 항의 보기는 정답 절대값 기준 음수/양수 두 개다 +- 두 항 선택 후 `정답 확인` 버튼으로 제출할 수 있다 +- 정답·오답 피드백이 기존 색상과 기호 규칙(`○`, `×`)으로 표시된다 +- 최종 결과 화면이 표시된다 +- 기존 숫자 괄호 풀이 화면은 현재 동작을 유지한다 +- `pnpm test` 통과 +- `pnpm build` 통과 diff --git a/lib/engine/variable-parenthesis.test.ts b/lib/engine/variable-parenthesis.test.ts new file mode 100644 index 0000000..66068bd --- /dev/null +++ b/lib/engine/variable-parenthesis.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + formatVariableExpressionLatex, + generateVariableParenthesisProblem, + generateVariableParenthesisSession, + gradeVariableParenthesisAnswer, +} from "./variable-parenthesis"; +import type { VariableParenthesisProblem } from "./variable-types"; + +function findCorrectChoiceIds(problem: VariableParenthesisProblem) { + return problem.correctTerms.map((term, index) => { + const choices = problem.choices[index as 0 | 1]; + const choice = choices.find( + (item) => + item.coefficient === term.coefficient && item.variable === term.variable, + ); + + if (!choice) throw new Error("correct choice not found."); + + return choice.id; + }) as [string, string]; +} + +describe("variable parenthesis engine", () => { + it("generates a problem with exactly one variable term inside parentheses", () => { + const problem = generateVariableParenthesisProblem(); + const terms = [problem.firstTerm, problem.secondTerm]; + + expect(terms.filter((term) => term.kind === "variable")).toHaveLength(1); + expect(terms.filter((term) => term.kind === "constant")).toHaveLength(1); + expect(problem.multiplier).toBeLessThan(0); + expect(Number.isInteger(problem.multiplier)).toBe(true); + expect(problem.firstTerm.coefficient).toBeGreaterThanOrEqual(1); + expect(problem.secondTerm.coefficient).toBeGreaterThanOrEqual(1); + }); + + it("distributes signs correctly for addition and subtraction", () => { + const problems = generateVariableParenthesisSession(50); + + for (const problem of problems) { + const secondSign = problem.operator === "-" ? -1 : 1; + + expect(problem.correctTerms[0].coefficient).toBe( + problem.multiplier * problem.firstTerm.coefficient, + ); + expect(problem.correctTerms[1].coefficient).toBe( + problem.multiplier * secondSign * problem.secondTerm.coefficient, + ); + } + }); + + it("creates positive and negative choices for each term", () => { + const problem = generateVariableParenthesisProblem(); + + problem.choices.forEach((choices, index) => { + const correctTerm = problem.correctTerms[index as 0 | 1]; + const coefficients = choices.map((choice) => choice.coefficient).sort(); + + expect(choices).toHaveLength(2); + expect(coefficients).toEqual([ + -Math.abs(correctTerm.coefficient), + Math.abs(correctTerm.coefficient), + ]); + expect(choices.every((choice) => choice.variable === correctTerm.variable)) + .toBe(true); + }); + }); + + it("grades selected choice ids", () => { + const problem = generateVariableParenthesisProblem(); + const correctChoiceIds = findCorrectChoiceIds(problem); + const result = gradeVariableParenthesisAnswer(problem, { + selectedChoiceIds: correctChoiceIds, + }); + + expect(result.correct).toBe(true); + expect(result.correctAnswer).toEqual(problem.correctTerms); + + const wrongFirstChoice = problem.choices[0].find( + (choice) => choice.id !== correctChoiceIds[0], + ); + + expect(wrongFirstChoice).toBeDefined(); + expect( + gradeVariableParenthesisAnswer(problem, { + selectedChoiceIds: [wrongFirstChoice!.id, correctChoiceIds[1]], + }).correct, + ).toBe(false); + }); + + it("generates sessions without duplicate keys", () => { + const problems = generateVariableParenthesisSession(10); + const keys = new Set(problems.map((problem) => problem.key)); + + expect(problems).toHaveLength(10); + expect(keys.size).toBe(10); + }); + + it("formats expressions with variable terms", () => { + expect( + formatVariableExpressionLatex([ + { kind: "variable", coefficient: -6, variable: "x", latex: "-6x" }, + { kind: "constant", coefficient: 12, latex: "12" }, + ]), + ).toBe("-6x + 12"); + }); +}); diff --git a/lib/engine/variable-parenthesis.ts b/lib/engine/variable-parenthesis.ts new file mode 100644 index 0000000..7bbe06d --- /dev/null +++ b/lib/engine/variable-parenthesis.ts @@ -0,0 +1,286 @@ +import type { + DistributedVariableTerm, + VariableParenthesisProblem, + VariableParenthesisTerm, + VariableTermChoice, + VariableUserAnswer, + VariableGradeResult, +} from "./variable-types"; + +const MIN_MULTIPLIER = -9; +const MAX_MULTIPLIER = -1; +const MIN_COEFFICIENT = 1; +const MAX_COEFFICIENT = 9; +const MAX_PROBLEM_COUNT = 9 * 9 * 9 * 2 * 2; + +interface VariableProblemSeed { + multiplier: number; + operator: "+" | "-"; + firstTerm: VariableParenthesisTerm; + secondTerm: VariableParenthesisTerm; +} + +function randomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function shuffle(items: T[]) { + const shuffled = [...items]; + + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const targetIndex = randomInt(0, index); + const current = shuffled[index]; + shuffled[index] = shuffled[targetIndex]; + shuffled[targetIndex] = current; + } + + return shuffled; +} + +function createConstantTerm(coefficient: number): VariableParenthesisTerm { + return { + kind: "constant", + coefficient, + }; +} + +function createVariableTerm(coefficient: number): VariableParenthesisTerm { + return { + kind: "variable", + coefficient, + variable: "x", + }; +} + +function termKey(term: VariableParenthesisTerm) { + return `${term.kind}:${term.coefficient}`; +} + +export function formatVariableTermLatex( + term: Pick, +) { + if (term.kind === "constant") { + return `${term.coefficient}`; + } + + if (term.coefficient === 1) { + return "x"; + } + + if (term.coefficient === -1) { + return "-x"; + } + + return `${term.coefficient}x`; +} + +function formatParenthesisTermLatex(term: VariableParenthesisTerm) { + return formatVariableTermLatex(term); +} + +export function formatVariableExpressionLatex( + terms: [DistributedVariableTerm, DistributedVariableTerm], +) { + const [firstTerm, secondTerm] = terms; + const operator = secondTerm.coefficient < 0 ? "-" : "+"; + const positiveSecondTerm = { + ...secondTerm, + coefficient: Math.abs(secondTerm.coefficient), + }; + + return `${firstTerm.latex} ${operator} ${formatVariableTermLatex( + positiveSecondTerm, + )}`; +} + +function distributeTerm( + multiplier: number, + term: VariableParenthesisTerm, + sign: 1 | -1, +): DistributedVariableTerm { + const coefficient = multiplier * sign * term.coefficient; + + return { + kind: term.kind, + coefficient, + variable: term.variable, + latex: formatVariableTermLatex({ ...term, coefficient }), + }; +} + +function createChoice( + key: string, + termIndex: 0 | 1, + term: DistributedVariableTerm, + coefficient: number, +): VariableTermChoice { + const signKey = coefficient < 0 ? "negative" : "positive"; + + return { + id: `${key}:term-${termIndex}:${signKey}`, + coefficient, + variable: term.variable, + latex: formatVariableTermLatex({ ...term, coefficient }), + }; +} + +function createChoices( + key: string, + termIndex: 0 | 1, + term: DistributedVariableTerm, +) { + const absoluteCoefficient = Math.abs(term.coefficient); + const choices: VariableTermChoice[] = [ + createChoice(key, termIndex, term, -absoluteCoefficient), + createChoice(key, termIndex, term, absoluteCoefficient), + ]; + + return shuffle(choices); +} + +function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem { + const { multiplier, operator, firstTerm, secondTerm } = seed; + const key = [ + multiplier, + termKey(firstTerm), + operator, + termKey(secondTerm), + ].join(":"); + const correctTerms: [DistributedVariableTerm, DistributedVariableTerm] = [ + distributeTerm(multiplier, firstTerm, 1), + distributeTerm(multiplier, secondTerm, operator === "-" ? -1 : 1), + ]; + const choices: [VariableTermChoice[], VariableTermChoice[]] = [ + createChoices(key, 0, correctTerms[0]), + createChoices(key, 1, correctTerms[1]), + ]; + + return { + id: `variable-parenthesis:${key}`, + key, + multiplier, + operator, + firstTerm, + secondTerm, + correctTerms, + choices, + latexBefore: `${multiplier}\\left(${formatParenthesisTermLatex( + firstTerm, + )} ${operator} ${formatParenthesisTermLatex(secondTerm)}\\right)`, + latexAfter: formatVariableExpressionLatex(correctTerms), + }; +} + +function createAllProblemSeeds() { + const seeds: VariableProblemSeed[] = []; + + for ( + let multiplier = MIN_MULTIPLIER; + multiplier <= MAX_MULTIPLIER; + multiplier += 1 + ) { + for ( + let constant = MIN_COEFFICIENT; + constant <= MAX_COEFFICIENT; + constant += 1 + ) { + for ( + let variableCoefficient = MIN_COEFFICIENT; + variableCoefficient <= MAX_COEFFICIENT; + variableCoefficient += 1 + ) { + for (const operator of ["+", "-"] as const) { + seeds.push({ + multiplier, + operator, + firstTerm: createVariableTerm(variableCoefficient), + secondTerm: createConstantTerm(constant), + }); + seeds.push({ + multiplier, + operator, + firstTerm: createConstantTerm(constant), + secondTerm: createVariableTerm(variableCoefficient), + }); + } + } + } + } + + return seeds; +} + +const ALL_PROBLEM_SEEDS = createAllProblemSeeds(); + +export function generateVariableParenthesisProblem(): VariableParenthesisProblem { + const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT); + const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT); + const variableFirst = Math.random() < 0.5; + const operator = Math.random() < 0.5 ? "+" : "-"; + + return createProblem({ + multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER), + operator, + firstTerm: variableFirst + ? createVariableTerm(variableCoefficient) + : createConstantTerm(constant), + secondTerm: variableFirst + ? createConstantTerm(constant) + : createVariableTerm(variableCoefficient), + }); +} + +export function generateVariableParenthesisSession( + count = 10, +): VariableParenthesisProblem[] { + if (!Number.isInteger(count) || count < 1) { + throw new Error("count must be a positive integer."); + } + + if (count > MAX_PROBLEM_COUNT) { + throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`); + } + + return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem); +} + +function findSelectedChoice( + problem: VariableParenthesisProblem, + termIndex: 0 | 1, + choiceId: string | null, +): DistributedVariableTerm | null { + if (!choiceId) return null; + + const choice = problem.choices[termIndex].find((item) => item.id === choiceId); + + if (!choice) return null; + + return { + kind: problem.correctTerms[termIndex].kind, + coefficient: choice.coefficient, + variable: choice.variable, + latex: choice.latex, + }; +} + +export function gradeVariableParenthesisAnswer( + problem: VariableParenthesisProblem, + answer: VariableUserAnswer, +): VariableGradeResult { + const userAnswer: [ + DistributedVariableTerm | null, + DistributedVariableTerm | null, + ] = [ + findSelectedChoice(problem, 0, answer.selectedChoiceIds[0]), + findSelectedChoice(problem, 1, answer.selectedChoiceIds[1]), + ]; + const correct = + userAnswer[0]?.coefficient === problem.correctTerms[0].coefficient && + userAnswer[1]?.coefficient === problem.correctTerms[1].coefficient; + + return { + correct, + userAnswer, + correctAnswer: problem.correctTerms, + }; +} diff --git a/lib/engine/variable-types.ts b/lib/engine/variable-types.ts new file mode 100644 index 0000000..b860148 --- /dev/null +++ b/lib/engine/variable-types.ts @@ -0,0 +1,44 @@ +export type VariableTermKind = "constant" | "variable"; + +export interface VariableParenthesisTerm { + kind: VariableTermKind; + coefficient: number; + variable?: "x"; +} + +export interface DistributedVariableTerm { + kind: VariableTermKind; + coefficient: number; + variable?: "x"; + latex: string; +} + +export interface VariableTermChoice { + id: string; + coefficient: number; + variable?: "x"; + latex: string; +} + +export interface VariableParenthesisProblem { + id: string; + key: string; + multiplier: number; + operator: "+" | "-"; + firstTerm: VariableParenthesisTerm; + secondTerm: VariableParenthesisTerm; + correctTerms: [DistributedVariableTerm, DistributedVariableTerm]; + choices: [VariableTermChoice[], VariableTermChoice[]]; + latexBefore: string; + latexAfter: string; +} + +export interface VariableUserAnswer { + selectedChoiceIds: [string | null, string | null]; +} + +export interface VariableGradeResult { + correct: boolean; + userAnswer: [DistributedVariableTerm | null, DistributedVariableTerm | null]; + correctAnswer: [DistributedVariableTerm, DistributedVariableTerm]; +} diff --git a/store/variableGameStore.ts b/store/variableGameStore.ts new file mode 100644 index 0000000..aacb0bb --- /dev/null +++ b/store/variableGameStore.ts @@ -0,0 +1,90 @@ +import { create } from "zustand"; + +import { + generateVariableParenthesisSession, + gradeVariableParenthesisAnswer, +} from "@/lib/engine/variable-parenthesis"; +import type { + VariableGradeResult, + VariableParenthesisProblem, + VariableUserAnswer, +} from "@/lib/engine/variable-types"; + +export interface VariableGameSession { + problems: VariableParenthesisProblem[]; + currentIndex: number; + results: (VariableGradeResult | null)[]; +} + +export type VariableProblemCount = 5 | 10; + +interface VariableGameState { + session: VariableGameSession | null; + selectedProblemCount: VariableProblemCount; + setProblemCount: (count: VariableProblemCount) => void; + initSession: (count?: VariableProblemCount) => void; + submitAnswer: (answer: VariableUserAnswer) => void; + nextProblem: () => void; + resetSession: () => void; +} + +const DEFAULT_SESSION_COUNT: VariableProblemCount = 10; + +function createSession( + count: VariableProblemCount = DEFAULT_SESSION_COUNT, +): VariableGameSession { + const problems = generateVariableParenthesisSession(count); + + return { + problems, + currentIndex: 0, + results: problems.map(() => null), + }; +} + +export const useVariableGameStore = create((set, get) => ({ + session: null, + selectedProblemCount: DEFAULT_SESSION_COUNT, + setProblemCount: (count) => { + set({ selectedProblemCount: count }); + }, + initSession: (count) => { + const selectedProblemCount = count ?? get().selectedProblemCount; + + set({ session: createSession(selectedProblemCount) }); + }, + submitAnswer: (answer) => { + const { session } = get(); + + if (!session || session.currentIndex >= session.problems.length) return; + if (session.results[session.currentIndex] !== null) return; + + const problem = session.problems[session.currentIndex]; + const result = gradeVariableParenthesisAnswer(problem, answer); + const results = [...session.results]; + results[session.currentIndex] = result; + + set({ + session: { + ...session, + results, + }, + }); + }, + nextProblem: () => { + const { session } = get(); + + if (!session || session.currentIndex >= session.problems.length) return; + if (session.results[session.currentIndex] === null) return; + + set({ + session: { + ...session, + currentIndex: session.currentIndex + 1, + }, + }); + }, + resetSession: () => { + set({ session: createSession(get().selectedProblemCount) }); + }, +}));