미지수 괄호 문제 풀이 추가
This commit is contained in:
@@ -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를 문제 생성에 사용하지 않는다.
|
||||
- 미지수의 계수는 항상 정수이어야 한다.
|
||||
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(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`으로 처리한다.
|
||||
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
|
||||
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` 통과
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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) });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user