미지수 괄호 문제 풀이 추가
This commit is contained in:
@@ -32,32 +32,39 @@ AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라
|
|||||||
## 디렉토리 구조 및 파일 위치 규칙
|
## 디렉토리 구조 및 파일 위치 규칙
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ # Next.js App Router 루트
|
app/ # Next.js App Router 루트
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
(game)/ # 게임 화면 라우트 그룹
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
number-parentheses/ # 숫자 괄호 준비 화면
|
||||||
select-type/ # 문제 유형 선택
|
variable-parentheses/ # 미지수 괄호 준비 화면
|
||||||
select-mode/ # 게임 모드 선택
|
play/ # 미지수 괄호 풀이 화면
|
||||||
play/ # 문제 풀이
|
select-mode/ # 게임 모드 선택 (미구현)
|
||||||
|
play/ # 숫자 괄호 풀이 화면
|
||||||
api/
|
api/
|
||||||
auth/ # NextAuth.js 핸들러
|
auth/ # NextAuth.js 핸들러
|
||||||
problem/ # 문제 생성 API
|
problem/ # 문제 생성 API
|
||||||
record/ # 풀이 기록 저장 API
|
record/ # 풀이 기록 저장 API
|
||||||
layout.tsx
|
layout.tsx
|
||||||
page.tsx # 시작 화면
|
page.tsx # 시작 화면 (학습 내용 선택)
|
||||||
components/
|
components/
|
||||||
math/ # KaTeX 수식 컴포넌트
|
math/ # KaTeX 수식 컴포넌트
|
||||||
game/ # 게임 UI 컴포넌트
|
game/ # 게임 UI 컴포넌트
|
||||||
lib/
|
lib/
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
engine/ # 문제 생성 알고리즘 엔진
|
||||||
prisma.ts # Prisma Client 싱글턴
|
parenthesis-sign.ts # 숫자 괄호 엔진
|
||||||
redis.ts # Redis Client 싱글턴
|
variable-parenthesis.ts # 미지수 괄호 엔진
|
||||||
|
types.ts # 숫자 괄호 타입
|
||||||
|
variable-types.ts # 미지수 괄호 타입
|
||||||
|
input-device.ts # 입력 장치 감지 미디어 쿼리 상수
|
||||||
|
prisma.ts # Prisma Client 싱글턴
|
||||||
|
redis.ts # Redis Client 싱글턴
|
||||||
store/
|
store/
|
||||||
gameStore.ts # Zustand 전역 게임 상태
|
gameStore.ts # 숫자 괄호 Zustand 전역 상태
|
||||||
|
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
|
||||||
types/
|
types/
|
||||||
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
|
*.d.ts # 타입 선언이 없는 서드파티 모듈 보강
|
||||||
prisma/
|
prisma/
|
||||||
schema.prisma # MariaDB 모델 정의
|
schema.prisma # MariaDB 모델 정의
|
||||||
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -78,7 +85,7 @@ prisma/
|
|||||||
|
|
||||||
### 상태 관리
|
### 상태 관리
|
||||||
|
|
||||||
- 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다.
|
- 숫자 괄호 전역 게임 상태는 `store/gameStore.ts`, 미지수 괄호 전역 게임 상태는 `store/variableGameStore.ts`의 Zustand store로 관리한다. 학습 유형별로 store를 분리한다.
|
||||||
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
|
- 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다.
|
||||||
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
|
- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다.
|
||||||
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
|
- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다.
|
||||||
@@ -93,18 +100,28 @@ prisma/
|
|||||||
### 접근성 / 키보드 조작
|
### 접근성 / 키보드 조작
|
||||||
|
|
||||||
- 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다.
|
- 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다.
|
||||||
- 문제 풀이 화면은 모든 항을 하나의 공유 input으로 입력한다.
|
- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다.
|
||||||
|
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
|
||||||
|
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
|
||||||
|
|
||||||
|
#### 숫자 괄호 풀이 화면 (`/play`)
|
||||||
|
|
||||||
|
- 모든 항을 하나의 공유 input으로 입력한다.
|
||||||
- input 아래에는 각 항의 입력값 버튼을 버튼 그룹으로 표시한다. 현재 입력 중인 항은 `aria-pressed`로 표시하고, 비어 있는 항은 `?`로 표시한다.
|
- input 아래에는 각 항의 입력값 버튼을 버튼 그룹으로 표시한다. 현재 입력 중인 항은 `aria-pressed`로 표시하고, 비어 있는 항은 `?`로 표시한다.
|
||||||
- 첫째 항에서 Enter를 누르면 같은 input이 둘째 항 입력으로 전환된다.
|
- 첫째 항에서 Enter를 누르면 같은 input이 둘째 항 입력으로 전환된다.
|
||||||
- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
|
- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
|
||||||
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
|
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
|
||||||
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다.
|
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts`의 `DESKTOP_INPUT_DEVICE_QUERY` 사용)
|
||||||
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
|
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
|
||||||
- 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다.
|
- 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다.
|
||||||
- 터치 기반 입력 환경에서 input focus 시 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다.
|
- 터치 기반 입력 환경에서 input focus 시 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다.
|
||||||
- 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다.
|
|
||||||
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
|
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
|
||||||
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
|
|
||||||
|
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
|
||||||
|
- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동한다.
|
||||||
|
- 두 항을 모두 선택하면 `정답 확인` 버튼에 focus가 이동하고 버튼이 활성화된다.
|
||||||
|
- 선택된 보기 버튼은 `aria-pressed`로 표시한다.
|
||||||
|
|
||||||
### API / 백엔드
|
### API / 백엔드
|
||||||
|
|
||||||
@@ -117,9 +134,14 @@ prisma/
|
|||||||
|
|
||||||
- 문제 생성 로직은 `lib/engine/` 하위에 위치한다.
|
- 문제 생성 로직은 `lib/engine/` 하위에 위치한다.
|
||||||
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
|
- 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다.
|
||||||
- 미지수의 계수는 항상 정수이어야 한다.
|
- 계수는 항상 정수이어야 한다.
|
||||||
- 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(1–3), 미지수(없음/x/x,y/x,y,z).
|
|
||||||
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
|
- 문제 세션에서 중복을 방지할 때는 랜덤 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 기반 실시간 점수판 (미구현) |
|
||||||
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 |
|
| 일반 풀이 | 시간 제한 없음, 틀린 유형 반복 출제 (미구현) |
|
||||||
|
|
||||||
### 화면 흐름
|
### 화면 흐름
|
||||||
|
|
||||||
```
|
```
|
||||||
학습 내용 선택 → 숫자 괄호 준비 → 문제 풀이 → 결과 및 기록
|
학습 내용 선택(/) → 준비 화면 → 문제 풀이 → 결과
|
||||||
```
|
```
|
||||||
|
|
||||||
- 시작 화면(`/`)은 학습 내용 선택 화면이다.
|
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
|
||||||
- 현재 활성 학습 내용은 `숫자 괄호 풀기`이며 `/number-parentheses`로 이동한다.
|
- 준비 화면은 문제 수 `[5] [10]` 버튼 그룹과 괄호 종류 버튼 그룹을 제공한다. 기본값은 `10`이며, 현재는 `()`만 선택 가능하다.
|
||||||
- `분수 괄호 풀기`, `실수 괄호 풀기`, `미지수 괄호 풀기`는 준비 중 항목으로 disabled 상태로 표시한다.
|
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
|
||||||
- 숫자 괄호 준비 화면(`/number-parentheses`)은 문제 수 `[5] [10]` 선택 버튼 그룹을 제공하고 기본값은 `10`이다.
|
- 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다.
|
||||||
- 숫자 괄호 준비 화면은 괄호 종류 `()`, `{}`, `[]` 버튼 그룹을 제공한다. 현재는 `()`만 선택 가능하고 `{}`, `[]`는 disabled 상태로 표시한다.
|
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
|
||||||
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 `/play?count=N`으로 이동한다.
|
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
|
||||||
- 문제 풀이 화면은 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로 렌더링합니다.
|
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
|
||||||
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
|
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
|
||||||
- 정답·오답 피드백은 각 항의 `계산식 = 내가 입력한 답` 형식으로 표시합니다.
|
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
|
||||||
- 정답 항목은 정답 색상 박스와 `○` 표시를 사용합니다.
|
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
|
||||||
- 오답 항목은 오답 색상 박스, 붉은색 입력값, `×`, 굵은 정답값을 함께 표시합니다.
|
- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다.
|
||||||
- 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한 답` 형식으로 다시 보여주고, 정답·오답 표시 규칙을 피드백 화면과 공유합니다.
|
- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다.
|
||||||
- 키보드 흐름을 지원합니다.
|
|
||||||
- 모든 항은 하나의 input에서 순서대로 입력
|
### 숫자 괄호 풀기 (`/number-parentheses` → `/play`)
|
||||||
- 첫째 항 Enter → 같은 input이 둘째 항 입력으로 전환
|
|
||||||
- 둘째 항 Enter → 제출
|
- 준비 화면에서 문제 수 5 / 10을 선택하고 연습을 시작합니다. 기본값은 10.
|
||||||
- 피드백 화면 → 다음 문제/결과 보기 버튼에 focus
|
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
|
||||||
- 다음 문제 이동 후 → input에 focus
|
- 풀이 화면에서 두 항을 직접 숫자로 입력합니다.
|
||||||
- 결과 화면 → 다시 시작 버튼에 focus
|
- 모든 항은 하나의 input에서 순서대로 입력하며, 첫째 항 Enter → 둘째 항 전환, 둘째 항 Enter → 제출.
|
||||||
- input 아래에는 각 항의 입력값 버튼이 표시됩니다. 비어 있는 항은 `?`로 표시하고, 버튼을 누르면 해당 항을 초기화한 뒤 다시 입력합니다.
|
- input 아래 항 버튼 그룹에서 현재 입력 중인 항을 표시하고, 버튼을 누르면 해당 항을 초기화해 다시 입력합니다.
|
||||||
- 터치 기반 입력 환경에서는 input focus 후 커스텀 숫자 키패드를 표시합니다.
|
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
|
||||||
- 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공합니다.
|
|
||||||
- `↵`는 첫째 항에서 둘째 항으로 이동하고, 둘째 항에서 제출합니다.
|
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
|
||||||
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주해 키패드를 숨깁니다.
|
|
||||||
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용하지만, 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과합니다.
|
- 준비 화면 구성은 숫자 괄호와 동일합니다.
|
||||||
|
- 풀이 화면에서 두 항을 보기 중에서 선택합니다.
|
||||||
|
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
|
||||||
|
- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동합니다.
|
||||||
|
- 두 항 선택 완료 후 `정답 확인` 버튼이 활성화되며 버튼에 focus가 이동합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,38 +122,43 @@ pnpm start
|
|||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ # Next.js App Router 루트
|
app/ # Next.js App Router 루트
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
(game)/ # 게임 화면 라우트 그룹
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
number-parentheses/ # 숫자 괄호 준비 화면
|
||||||
select-type/ # 문제 유형 선택
|
variable-parentheses/ # 미지수 괄호 준비 화면
|
||||||
select-mode/ # 게임 모드 선택
|
play/ # 미지수 괄호 풀이 화면
|
||||||
play/ # 문제 풀이
|
play/ # 숫자 괄호 풀이 화면
|
||||||
api/
|
api/
|
||||||
auth/ # NextAuth.js 핸들러
|
auth/ # NextAuth.js 핸들러
|
||||||
problem/ # 문제 생성 API
|
problem/ # 문제 생성 API
|
||||||
record/ # 풀이 기록 저장 API
|
record/ # 풀이 기록 저장 API
|
||||||
layout.tsx
|
layout.tsx
|
||||||
page.tsx # 시작 화면
|
page.tsx # 시작 화면 (학습 내용 선택)
|
||||||
components/
|
components/
|
||||||
math/ # KaTeX 수식 컴포넌트
|
math/ # KaTeX 수식 컴포넌트
|
||||||
game/ # 게임 UI 컴포넌트, 피드백/결과 공통 표시 컴포넌트
|
game/ # 게임 UI 컴포넌트
|
||||||
lib/
|
lib/
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
engine/ # 문제 생성 알고리즘 엔진
|
||||||
prisma.ts # Prisma Client 싱글턴
|
parenthesis-sign.ts # 숫자 괄호 엔진
|
||||||
redis.ts # Redis Client 싱글턴
|
variable-parenthesis.ts # 미지수 괄호 엔진
|
||||||
|
input-device.ts # 입력 장치 감지 미디어 쿼리
|
||||||
|
prisma.ts # Prisma Client 싱글턴
|
||||||
|
redis.ts # Redis Client 싱글턴
|
||||||
store/
|
store/
|
||||||
gameStore.ts # Zustand 전역 게임 상태
|
gameStore.ts # 숫자 괄호 Zustand 전역 상태
|
||||||
|
variableGameStore.ts # 미지수 괄호 Zustand 전역 상태
|
||||||
prisma/
|
prisma/
|
||||||
schema.prisma # MariaDB 모델 정의
|
schema.prisma # MariaDB 모델 정의
|
||||||
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
.gitea/workflows/deploy.yml # CI/CD 워크플로우
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 다음 확장 방향
|
## 다음 확장 방향
|
||||||
|
|
||||||
- 분수 괄호, 실수 괄호, 미지수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
|
- 분수 괄호, 실수 괄호는 각 문제 생성 엔진이 준비되면 시작 화면에서 활성화합니다.
|
||||||
- `{}`, `[]` 괄호 종류는 생성 엔진과 상태/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: "미지수 괄호 풀기",
|
label: "미지수 괄호 풀기",
|
||||||
description: "미지수가 들어간 괄호식은 이후 단계에서 추가됩니다.",
|
description: "괄호 안에 미지수 x가 들어간 식의 부호 변화를 연습합니다.",
|
||||||
disabled: true,
|
href: "/variable-parentheses",
|
||||||
|
disabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
||||||
<p>지금은 숫자 괄호 학습만 사용할 수 있습니다.</p>
|
<p>지금은 숫자 괄호와 미지수 괄호 학습을 사용할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
interface StageResult {
|
||||||
|
correct: boolean;
|
||||||
import type { GradeResult } from "@/lib/engine/types";
|
}
|
||||||
|
|
||||||
interface StageIndicatorProps {
|
interface StageIndicatorProps {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
results: (GradeResult | null)[];
|
results: (StageResult | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StageIndicator({
|
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