미지수 괄호 문제 풀이 추가

This commit is contained in:
2026-05-29 23:47:10 +09:00
parent 335898b721
commit f3d051cdd1
16 changed files with 1789 additions and 105 deletions
+51 -28
View File
@@ -35,24 +35,31 @@ AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
number-parentheses/ # 숫자 괄호 준비 화면
select-type/ # 문제 유형 선택
select-mode/ # 게임 모드 선택
play/ # 문제 풀이
variable-parentheses/ # 미지수 괄호 준비 화면
play/ # 미지수 괄호 풀이 화면
select-mode/ # 게임 모드 선택 (미구현)
play/ # 숫자 괄호 풀이 화면
api/
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
layout.tsx
page.tsx # 시작 화면
page.tsx # 시작 화면 (학습 내용 선택)
components/
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
lib/
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 # 타입 선언이 없는 서드파티 모듈 보강
prisma/
@@ -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를 문제 생성에 사용하지 않는다.
- 미지수의 계수는 항상 정수이어야 한다.
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(13), 미지수(없음/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`으로 처리한다.
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
---
+45 -44
View File
@@ -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가 이동합니다.
---
@@ -129,24 +125,28 @@ pnpm start
app/ # Next.js App Router 루트
(game)/ # 게임 화면 라우트 그룹
number-parentheses/ # 숫자 괄호 준비 화면
select-type/ # 문제 유형 선택
select-mode/ # 게임 모드 선택
play/ # 문제 풀이
variable-parentheses/ # 미지수 괄호 준비 화면
play/ # 미지수 괄호 풀이 화면
play/ # 숫자 괄호 풀이 화면
api/
auth/ # NextAuth.js 핸들러
problem/ # 문제 생성 API
record/ # 풀이 기록 저장 API
layout.tsx
page.tsx # 시작 화면
page.tsx # 시작 화면 (학습 내용 선택)
components/
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트, 피드백/결과 공통 표시 컴포넌트
game/ # 게임 UI 컴포넌트
lib/
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 워크플로우
@@ -156,8 +156,9 @@ prisma/
## 다음 확장 방향
- 분수 괄호, 실수 괄호, 미지수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 숫자 괄호 준비 화면에서 활성화합니다.
- 분수 괄호, 실수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/URL 파라미터 연동이 준비된 뒤 준비 화면에서 활성화합니다.
- 스피드 퀴즈 모드, 풀이 기록 저장, 로그인 기능은 DB/Redis/NextAuth 연동 후 추가합니다.
---
@@ -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<HTMLAnchorElement>(null);
const selectedProblemCount = useVariableGameStore(
(state) => state.selectedProblemCount,
);
const setProblemCount = useVariableGameStore(
(state) => state.setProblemCount,
);
const initSession = useVariableGameStore((state) => state.initSession);
useEffect(() => {
startLinkRef.current?.focus();
}, []);
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-4">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-700">
x가
.
</p>
</div>
<div className="flex flex-col gap-6">
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
role="group"
aria-label="문제 수 선택"
>
{problemCounts.map((count) => {
const selected = selectedProblemCount === count;
return (
<button
key={count}
type="button"
onClick={() => setProblemCount(count)}
aria-pressed={selected}
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
selected
? "bg-emerald-700 text-white"
: "text-slate-700 hover:bg-slate-100"
}`}
>
{count}
</button>
);
})}
</div>
</fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-slate-700">
</legend>
<div
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
role="group"
aria-label="괄호 종류 선택"
>
{bracketKinds.map((bracketKind) => (
<button
key={bracketKind.label}
type="button"
disabled={bracketKind.disabled}
aria-pressed={bracketKind.selected}
aria-label={`${bracketKind.name} ${bracketKind.disabled ? "준비 중" : "선택됨"}`}
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
bracketKind.selected
? "bg-emerald-700 text-white"
: "text-slate-700 hover:bg-slate-100"
} ${
bracketKind.disabled
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
: ""
}`}
>
{bracketKind.label}
</button>
))}
</div>
</fieldset>
<div>
<Link
ref={startLinkRef}
href={`/variable-parentheses/play?count=${selectedProblemCount}`}
onClick={() => 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"
>
</Link>
</div>
</div>
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
<p> x .</p>
</div>
</section>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import VariableParenthesesClient from "./VariableParenthesesClient";
export default function VariableParenthesesPage() {
return <VariableParenthesesClient />;
}
@@ -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 (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
if (isComplete) {
return (
<VariableScoreSummary
correctCount={correctCount}
totalCount={totalCount}
problems={session.problems}
results={session.results}
onRestart={resetSession}
/>
);
}
if (!currentProblem) {
return (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto w-full max-w-3xl">
<p className="text-lg font-semibold text-slate-700">
.
</p>
</section>
</main>
);
}
const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1;
return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
</h1>
</div>
<StageIndicator
currentIndex={session.currentIndex}
results={session.results}
/>
</div>
<div className="rounded-md bg-white px-6 pt-6 pb-2 shadow-sm ring-1 ring-slate-200 sm:p-8">
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
</div>
</div>
{!submitted ? (
<VariableChoiceAnswerForm
choices={currentProblem.choices}
selectedChoiceIds={selectedChoiceIds}
activeTermIndex={activeTermIndex}
onSelectChoice={handleSelectChoice}
onSubmit={handleSubmit}
/>
) : null}
{currentResult ? (
<VariableFeedbackPanel
result={currentResult}
problem={currentProblem}
isLastProblem={isLastProblem}
onNext={handleNextProblem}
/>
) : null}
</section>
</main>
);
}
@@ -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 (
<VariablePlayClient problemCount={parseProblemCount(searchParams?.count)} />
);
}
+4 -3
View File
@@ -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() {
</div>
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
<p> .</p>
<p> .</p>
</div>
</section>
</main>
+4 -4
View File
@@ -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({
@@ -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<HTMLButtonElement>(null);
const submitButtonRef = useRef<HTMLButtonElement>(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 (
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-500">
{activeTermIndex === 0 ? "첫째 항" : "둘째 항"} .
</p>
<p className="mt-1 text-base text-slate-700">
.
</p>
</div>
<div
className="inline-flex gap-1 rounded-md border border-slate-300 bg-slate-50 p-1"
role="group"
aria-label="입력 항 진행 상태"
>
{selectedChoiceIds.map((choiceId, index) => {
const selected = activeTermIndex === index;
return (
<span
key={index}
className={`inline-flex h-9 min-w-16 items-center justify-center rounded px-3 text-sm font-bold ${
selected
? "bg-emerald-700 text-white"
: "bg-white text-slate-700"
}`}
>
{choiceId ? `${index + 1}항 완료` : `${index + 1}`}
</span>
);
})}
</div>
</div>
<div
className="mt-5 grid gap-3 sm:grid-cols-2"
role="group"
aria-label={`${activeTermIndex === 0 ? "첫째 항" : "둘째 항"} 보기 선택`}
>
{activeChoices.map((choice, index) => {
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
return (
<button
key={choice.id}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
onClick={() => onSelectChoice(activeTermIndex, choice.id)}
aria-pressed={selected}
className={`flex h-20 items-center justify-center rounded-md text-3xl font-bold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
selected
? "bg-emerald-700 text-white"
: "bg-slate-50 text-slate-950 ring-1 ring-slate-200 hover:bg-slate-100"
}`}
>
<KatexRenderer latex={choice.latex} />
</button>
);
})}
</div>
<button
ref={submitButtonRef}
type="button"
onClick={onSubmit}
disabled={!canSubmit}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
>
</button>
</div>
);
}
+90
View File
@@ -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<HTMLButtonElement>(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 (
<div
className={`rounded-md p-6 shadow-sm ring-1 ${
result.correct
? "bg-emerald-50 text-emerald-950 ring-emerald-200"
: "bg-red-50 text-red-950 ring-red-200"
}`}
>
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<ul className="mt-5 space-y-3">
{feedbackItems.map((item, index) => (
<AnswerResultLine
key={index}
expressionLatex={item.formulaLatex}
userAnswerLatex={item.userAnswerLatex}
correctAnswerLatex={item.correctAnswerLatex}
correct={item.correct}
/>
))}
</ul>
<button
ref={nextButtonRef}
type="button"
onClick={onNext}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
>
{isLastProblem ? "결과 보기" : "다음 문제"}
</button>
</div>
);
}
+157
View File
@@ -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 (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{terms.map((term, index) => {
if (!term) {
return (
<span key={index} className="text-red-700">
?
</span>
);
}
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 (
<span key={index} className={className}>
<KatexRenderer latex={displayLatex} output="mathml" />
</span>
);
})}
</span>
);
}
export default function VariableScoreSummary({
correctCount,
totalCount,
problems,
results,
onRestart,
}: VariableScoreSummaryProps) {
const restartButtonRef = useRef<HTMLButtonElement>(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 (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<div className="space-y-3">
<p className="text-sm font-semibold text-emerald-700">
</p>
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
</h1>
<p className="text-xl font-semibold text-slate-800">
{correctCount} / {totalCount}
</p>
</div>
<ol className="space-y-3">
{solvedItems.map(({ problem, result }, index) => (
<AnswerResultLine
key={problem.key}
expressionLatex={problem.latexBefore}
userAnswerLatex={formatVariableExpressionLatex([
result.userAnswer[0] ?? result.correctAnswer[0],
result.userAnswer[1] ?? result.correctAnswer[1],
])}
correctAnswerLatex={problem.latexAfter}
correct={result.correct}
userAnswerContent={renderTermPair(
result.userAnswer,
result.correctAnswer,
"user",
)}
correctAnswerContent={renderTermPair(
result.correctAnswer,
result.userAnswer,
"correct",
)}
prefix={
<span className="mr-1 text-sm font-bold text-slate-500">
{index + 1}.
</span>
}
/>
))}
</ol>
<div className="flex flex-wrap gap-3">
<button
ref={restartButtonRef}
type="button"
onClick={onRestart}
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"
>
</button>
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 px-6 text-base font-semibold text-slate-800 transition hover:bg-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
</Link>
</div>
</section>
</main>
);
}
+413
View File
@@ -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` 통과
+108
View File
@@ -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");
});
});
+286
View File
@@ -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<T>(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<VariableParenthesisTerm, "kind" | "coefficient" | "variable">,
) {
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,
};
}
+44
View File
@@ -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];
}
+90
View File
@@ -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<VariableGameState>((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) });
},
}));