괄호 앞 음수 분배 스테이지 추가
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import KatexRenderer from "@/components/math/KatexRenderer";
|
||||||
|
import { useGameStore } from "@/store/gameStore";
|
||||||
|
|
||||||
|
function parseAnswer(value: string) {
|
||||||
|
if (value.trim() === "") return null;
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayClient() {
|
||||||
|
const session = useGameStore((state) => state.session);
|
||||||
|
const initSession = useGameStore((state) => state.initSession);
|
||||||
|
const submitAnswer = useGameStore((state) => state.submitAnswer);
|
||||||
|
const nextProblem = useGameStore((state) => state.nextProblem);
|
||||||
|
const resetSession = useGameStore((state) => state.resetSession);
|
||||||
|
|
||||||
|
const [answerA, setAnswerA] = useState("");
|
||||||
|
const [answerB, setAnswerB] = useState("");
|
||||||
|
const [inputError, setInputError] = useState("");
|
||||||
|
|
||||||
|
const currentProblem = session?.problems[session.currentIndex] ?? null;
|
||||||
|
const currentResult = session?.results[session.currentIndex] ?? null;
|
||||||
|
const isComplete = Boolean(
|
||||||
|
session && session.currentIndex >= session.problems.length,
|
||||||
|
);
|
||||||
|
const correctCount =
|
||||||
|
session?.results.filter((result) => result?.correct).length ?? 0;
|
||||||
|
const totalCount = session?.problems.length ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session === null) {
|
||||||
|
initSession();
|
||||||
|
}
|
||||||
|
}, [initSession, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAnswerA("");
|
||||||
|
setAnswerB("");
|
||||||
|
setInputError("");
|
||||||
|
}, [currentProblem?.key]);
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (currentResult || !currentProblem) return;
|
||||||
|
|
||||||
|
const parsedA = parseAnswer(answerA);
|
||||||
|
const parsedB = parseAnswer(answerB);
|
||||||
|
|
||||||
|
if (parsedA === null || parsedB === null) {
|
||||||
|
setInputError("두 칸 모두 정수로 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputError("");
|
||||||
|
submitAnswer({ a: parsedA, b: parsedB });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetSession}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="rounded-md bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm ring-1 ring-slate-200">
|
||||||
|
{session.currentIndex + 1} / {session.problems.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-white p-6 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>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
|
||||||
|
첫째 항
|
||||||
|
<input
|
||||||
|
value={answerA}
|
||||||
|
onChange={(event) => setAnswerA(event.target.value)}
|
||||||
|
disabled={submitted}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
|
||||||
|
둘째 항
|
||||||
|
<input
|
||||||
|
value={answerB}
|
||||||
|
onChange={(event) => setAnswerB(event.target.value)}
|
||||||
|
disabled={submitted}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-12 rounded-md border border-slate-300 px-4 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 disabled:bg-slate-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inputError ? (
|
||||||
|
<p className="mt-3 text-sm font-semibold text-red-700">
|
||||||
|
{inputError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitted}
|
||||||
|
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 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
제출
|
||||||
|
</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-slate-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
처음으로
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{currentResult ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-md p-6 shadow-sm ring-1 ${
|
||||||
|
currentResult.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">
|
||||||
|
{currentResult.correct ? "정답입니다." : "오답입니다."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-xl font-semibold">
|
||||||
|
<KatexRenderer latex={currentProblem.latexAfter} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextProblem}
|
||||||
|
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>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,5 @@
|
|||||||
import Link from "next/link";
|
import PlayClient from "./PlayClient";
|
||||||
|
|
||||||
export default function PlayPage() {
|
export default function PlayPage() {
|
||||||
return (
|
return <PlayClient />;
|
||||||
<main className="flex min-h-screen items-center bg-white 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">
|
|
||||||
다음 단계에서 괄호 앞 음수 분배 문제가 이 화면에 표시됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-md border border-slate-300 px-5 text-base font-semibold text-slate-800 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
처음으로
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BlockMath, InlineMath } from "react-katex";
|
||||||
|
|
||||||
|
interface KatexRendererProps {
|
||||||
|
latex: string;
|
||||||
|
displayMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KatexRenderer({
|
||||||
|
latex,
|
||||||
|
displayMode = false,
|
||||||
|
}: KatexRendererProps) {
|
||||||
|
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# 02. 괄호 앞 음수 분배 — 간단한 풀이 화면
|
||||||
|
|
||||||
|
## 계획 평가
|
||||||
|
|
||||||
|
Claude Code가 작성한 기존 계획은 방향이 맞다. `01-project-start.md`에서 만든 `/` -> `/play` 흐름 위에 문제 생성 엔진, KaTeX 표시, 답 입력, 채점, 10문제 세션을 붙이는 순서가 자연스럽다.
|
||||||
|
|
||||||
|
다만 실제 구현 단계로 쓰기에는 몇 가지를 조정하는 편이 좋다.
|
||||||
|
|
||||||
|
- 엔진 단위 테스트만 하는 단계에서는 `@vitejs/plugin-react`, `jsdom`, `@testing-library/react`가 아직 필요하지 않다.
|
||||||
|
- `generateSession()`의 중복 방지는 랜덤 `id`가 아니라 `multiplier/a/operator/b` 조합 기준이어야 한다.
|
||||||
|
- Zustand는 한 문제 풀이가 동작한 뒤 도입한다. 이 순서를 지키면 버그가 엔진, UI, 상태 관리 중 어디에서 생겼는지 분리하기 쉽다.
|
||||||
|
- `PlayClient`에서 세션을 초기화할 때 렌더링 중 `initSession()`을 직접 호출하지 않고 `useEffect`에서 호출해야 한다.
|
||||||
|
- 이번 문서는 “간단한 풀이 화면”이 목표이므로 Framer Motion, 정교한 진행 바, 배너 디자인, E2E는 다음 단계로 넘긴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
`/play` 화면에서 **괄호 앞 음수 분배** 문제를 10개 풀고 점수를 확인할 수 있게 만든다.
|
||||||
|
|
||||||
|
이번 단계의 최종 상태는 다음과 같다.
|
||||||
|
|
||||||
|
- 자체 알고리즘으로 괄호 앞 음수 분배 문제를 생성한다.
|
||||||
|
- 문제와 정답 수식을 KaTeX로 렌더링한다.
|
||||||
|
- 사용자가 두 항의 답을 입력하고 채점 결과를 확인한다.
|
||||||
|
- 10문제 풀이가 끝나면 점수 요약을 보여준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이번 단계에서 할 것
|
||||||
|
|
||||||
|
- `lib/engine/`에 문제 생성·채점 순수 함수와 Vitest 단위 테스트 추가
|
||||||
|
- `components/math/`에 KaTeX 렌더링 컴포넌트 추가
|
||||||
|
- `/play`에 한 문제 풀이 UI 구현
|
||||||
|
- 한 문제 풀이 확인 후 `store/gameStore.ts`로 10문제 세션 구현
|
||||||
|
- 세션 완료 후 간단한 점수 요약 표시
|
||||||
|
|
||||||
|
## 이번 단계에서 하지 않을 것
|
||||||
|
|
||||||
|
- Framer Motion 애니메이션
|
||||||
|
- 정교한 ProgressBar, FeedbackBanner, ScoreSummary 디자인 컴포넌트화
|
||||||
|
- DB, Prisma, Redis, NextAuth
|
||||||
|
- 유형 선택 화면, 모드 선택 화면
|
||||||
|
- 스피드 퀴즈 모드
|
||||||
|
- 미지수 `x`, `y`, `z`가 포함된 문제
|
||||||
|
- Playwright E2E 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
|
||||||
|
필요한 시점에 나누어 설치한다.
|
||||||
|
|
||||||
|
### 엔진 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -D vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수식 렌더링과 세션 상태
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add katex react-katex zustand
|
||||||
|
pnpm add -D @types/katex
|
||||||
|
```
|
||||||
|
|
||||||
|
React 컴포넌트 테스트를 추가하지 않는 한 `@testing-library/react`, `jsdom`, `@vitejs/plugin-react`는 이번 단계에 넣지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 생성 또는 수정 파일
|
||||||
|
|
||||||
|
### 신규 생성
|
||||||
|
|
||||||
|
```text
|
||||||
|
lib/engine/types.ts
|
||||||
|
lib/engine/parenthesis-sign.ts
|
||||||
|
lib/engine/parenthesis-sign.test.ts
|
||||||
|
vitest.config.ts
|
||||||
|
components/math/KatexRenderer.tsx
|
||||||
|
store/gameStore.ts
|
||||||
|
app/(game)/play/PlayClient.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
```text
|
||||||
|
package.json # test script 추가
|
||||||
|
app/layout.tsx # KaTeX CSS import 추가
|
||||||
|
app/(game)/play/page.tsx # PlayClient 마운트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 순서
|
||||||
|
|
||||||
|
### 2-A. 문제 생성 엔진
|
||||||
|
|
||||||
|
완료 기준: `pnpm test` 통과
|
||||||
|
|
||||||
|
`lib/engine/types.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ParenthesisSignProblem {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
multiplier: number
|
||||||
|
a: number
|
||||||
|
operator: '+' | '-'
|
||||||
|
b: number
|
||||||
|
distributedA: number
|
||||||
|
distributedB: number
|
||||||
|
latexBefore: string
|
||||||
|
latexAfter: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAnswer {
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradeResult {
|
||||||
|
correct: boolean
|
||||||
|
correctAnswer: [number, number]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/engine/parenthesis-sign.ts`에 구현할 함수:
|
||||||
|
|
||||||
|
- `generateProblem(): ParenthesisSignProblem`
|
||||||
|
- `multiplier`: `-9`부터 `-1` 사이 정수
|
||||||
|
- `a`, `b`: `1`부터 `9` 사이 정수
|
||||||
|
- `a !== b`
|
||||||
|
- `operator`: `'+' | '-'`
|
||||||
|
- `distributedA = multiplier * a`
|
||||||
|
- `distributedB = operator === '-' ? multiplier * -b : multiplier * b`
|
||||||
|
- `key`는 `multiplier:a:operator:b` 형식으로 만든다.
|
||||||
|
- `latexBefore`: 예시 `-2(3 - 6)`
|
||||||
|
- `latexAfter`: 예시 `-6 + 12`
|
||||||
|
|
||||||
|
- `generateSession(count = 10): ParenthesisSignProblem[]`
|
||||||
|
- `count`가 `1` 이상인지 확인한다.
|
||||||
|
- 가능한 문제 수는 `9 * 8 * 2 = 144`개이므로 `count > 144`이면 에러를 던진다.
|
||||||
|
- 중복 방지는 `id`가 아니라 `key` 기준으로 한다.
|
||||||
|
|
||||||
|
- `gradeAnswer(problem, answer): GradeResult`
|
||||||
|
- 두 항이 모두 일치할 때만 정답이다.
|
||||||
|
- 입력 순서를 바꾸어 쓴 답은 오답으로 처리한다.
|
||||||
|
|
||||||
|
`lib/engine/parenthesis-sign.test.ts` 검증 항목:
|
||||||
|
|
||||||
|
- `multiplier`는 항상 음수 정수다.
|
||||||
|
- `a`, `b`는 범위 안에 있고 서로 다르다.
|
||||||
|
- `distributedA`, `distributedB` 계산이 `operator`별로 맞다.
|
||||||
|
- `gradeAnswer()`는 정답과 오답을 정확히 구분한다.
|
||||||
|
- `generateSession(10)`은 10개 문제를 만들고 `key` 중복이 없다.
|
||||||
|
- `generateSession(145)`는 에러를 던진다.
|
||||||
|
|
||||||
|
`vitest.config.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test": "vitest run"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-B. KaTeX 렌더링
|
||||||
|
|
||||||
|
완료 기준: 브라우저에서 문제 수식이 KaTeX로 표시됨
|
||||||
|
|
||||||
|
`app/layout.tsx`에 전역 CSS를 추가한다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
`components/math/KatexRenderer.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex'
|
||||||
|
|
||||||
|
interface KatexRendererProps {
|
||||||
|
latex: string
|
||||||
|
displayMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KatexRenderer({
|
||||||
|
latex,
|
||||||
|
displayMode = false,
|
||||||
|
}: KatexRendererProps) {
|
||||||
|
return displayMode ? <BlockMath math={latex} /> : <InlineMath math={latex} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이 단계에서는 `/play`에 임시 문제 하나를 표시해서 KaTeX 렌더링만 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-C. 한 문제 풀이 UI
|
||||||
|
|
||||||
|
완료 기준: 한 문제를 풀고 정답·오답 피드백이 표시됨
|
||||||
|
|
||||||
|
`app/(game)/play/PlayClient.tsx`를 클라이언트 컴포넌트로 만든다.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
```
|
||||||
|
|
||||||
|
구성:
|
||||||
|
|
||||||
|
- 문제 수식: `KatexRenderer`로 `problem.latexBefore` 표시
|
||||||
|
- 답 입력: 숫자 입력 두 칸
|
||||||
|
- 제출 버튼
|
||||||
|
- Enter 제출
|
||||||
|
- 정답이면 간단한 성공 문구
|
||||||
|
- 오답이면 정답 수식과 정답 두 항 표시
|
||||||
|
- 새 문제 버튼
|
||||||
|
|
||||||
|
로컬 상태:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [problem, setProblem] = useState<ParenthesisSignProblem>(() => generateProblem())
|
||||||
|
const [answerA, setAnswerA] = useState('')
|
||||||
|
const [answerB, setAnswerB] = useState('')
|
||||||
|
const [result, setResult] = useState<GradeResult | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
입력 처리:
|
||||||
|
|
||||||
|
- 빈 문자열은 제출하지 않는다.
|
||||||
|
- `Number.parseInt(value, 10)` 결과가 `NaN`이면 제출하지 않는다.
|
||||||
|
- 채점 후에는 같은 문제에서 중복 제출하지 않도록 제출 버튼을 비활성화한다.
|
||||||
|
|
||||||
|
이 단계에서는 Zustand를 사용하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-D. 10문제 세션
|
||||||
|
|
||||||
|
완료 기준: 10문제를 모두 풀면 점수 요약 화면이 표시됨
|
||||||
|
|
||||||
|
`store/gameStore.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GameSession {
|
||||||
|
problems: ParenthesisSignProblem[]
|
||||||
|
currentIndex: number
|
||||||
|
results: (GradeResult | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
session: GameSession | null
|
||||||
|
initSession: (count?: number) => void
|
||||||
|
submitAnswer: (answer: UserAnswer) => void
|
||||||
|
nextProblem: () => void
|
||||||
|
resetSession: () => void
|
||||||
|
currentProblem: () => ParenthesisSignProblem | null
|
||||||
|
currentResult: () => GradeResult | null
|
||||||
|
isComplete: () => boolean
|
||||||
|
score: () => { correct: number; total: number }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
상태 규칙:
|
||||||
|
|
||||||
|
- `initSession(10)`은 10개 문제와 `null` 결과 배열을 만든다.
|
||||||
|
- `submitAnswer()`는 현재 문제의 결과가 `null`일 때만 채점한다.
|
||||||
|
- `nextProblem()`은 채점 후에만 다음 문제로 이동한다.
|
||||||
|
- 마지막 문제에서 `nextProblem()`을 호출하면 완료 상태가 된다.
|
||||||
|
- `resetSession()`은 기존 세션을 버리고 새 세션을 시작한다.
|
||||||
|
|
||||||
|
`PlayClient.tsx` 전환:
|
||||||
|
|
||||||
|
- `useEffect`에서 `session === null`이면 `initSession()` 호출
|
||||||
|
- `currentProblem() === null`이면 로딩 또는 완료 화면 표시
|
||||||
|
- 풀이 중에는 현재 문제 번호를 텍스트로 표시한다. 예: `3 / 10`
|
||||||
|
- 세션 완료 후 맞은 개수와 전체 개수를 표시한다.
|
||||||
|
- 다시 시작 버튼은 `resetSession()`을 호출한다.
|
||||||
|
|
||||||
|
`app/(game)/play/page.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import PlayClient from './PlayClient'
|
||||||
|
|
||||||
|
export default function PlayPage() {
|
||||||
|
return <PlayClient />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 화면 흐름
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ 시작 화면
|
||||||
|
- "연습 시작" 클릭
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
/play 풀이 화면
|
||||||
|
├─ 현재 문제 번호
|
||||||
|
├─ 문제 수식 (KaTeX)
|
||||||
|
├─ 답 입력 두 칸
|
||||||
|
├─ 제출 버튼
|
||||||
|
├─ 정답/오답 피드백
|
||||||
|
├─ 다음 문제 버튼
|
||||||
|
└─ 10문제 완료 후 점수 요약
|
||||||
|
- n / 10 정답
|
||||||
|
- 다시 시작
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- `pnpm test` 통과
|
||||||
|
- `pnpm lint` 통과
|
||||||
|
- 브라우저에서 `/play` 수식이 KaTeX로 렌더링된다.
|
||||||
|
- 정답 입력 시 정답 피드백이 표시된다.
|
||||||
|
- 오답 입력 시 오답 피드백과 정답이 표시된다.
|
||||||
|
- 10문제를 모두 풀면 점수 요약 화면이 표시된다.
|
||||||
|
- 다시 시작을 누르면 새 10문제 세션이 시작된다.
|
||||||
|
|
||||||
|
## 확인 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm lint
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저 확인은 `pnpm dev` 실행 후 `http://localhost:3000/play`에서 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
`03-ux-polish.md`에서 UI 품질을 높인다.
|
||||||
|
|
||||||
|
- 진행률 바
|
||||||
|
- 정답·오답 배너 컴포넌트
|
||||||
|
- 점수 요약 컴포넌트
|
||||||
|
- Framer Motion 기반 전환 애니메이션
|
||||||
|
- 키보드 탐색과 포커스 처리 개선
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateProblem,
|
||||||
|
generateSession,
|
||||||
|
gradeAnswer,
|
||||||
|
} from "./parenthesis-sign";
|
||||||
|
|
||||||
|
describe("parenthesis sign problem engine", () => {
|
||||||
|
it("generates a problem within the configured ranges", () => {
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
const problem = generateProblem();
|
||||||
|
|
||||||
|
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
||||||
|
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
|
||||||
|
expect(problem.multiplier).toBeLessThanOrEqual(-1);
|
||||||
|
expect(problem.a).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(problem.a).toBeLessThanOrEqual(9);
|
||||||
|
expect(problem.b).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(problem.b).toBeLessThanOrEqual(9);
|
||||||
|
expect(problem.a).not.toBe(problem.b);
|
||||||
|
expect(["+", "-"]).toContain(problem.operator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes the multiplier correctly for both operators", () => {
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
const problem = generateProblem();
|
||||||
|
const signedB = problem.operator === "-" ? -problem.b : problem.b;
|
||||||
|
|
||||||
|
expect(problem.distributedA).toBe(problem.multiplier * problem.a);
|
||||||
|
expect(problem.distributedB).toBe(problem.multiplier * signedB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grades correct and incorrect answers", () => {
|
||||||
|
const problem = generateProblem();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
gradeAnswer(problem, {
|
||||||
|
a: problem.distributedA,
|
||||||
|
b: problem.distributedB,
|
||||||
|
}).correct,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
gradeAnswer(problem, {
|
||||||
|
a: problem.distributedB,
|
||||||
|
b: problem.distributedA,
|
||||||
|
}).correct,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a session with unique problem keys", () => {
|
||||||
|
const problems = generateSession(10);
|
||||||
|
const keys = new Set(problems.map((problem) => problem.key));
|
||||||
|
|
||||||
|
expect(problems).toHaveLength(10);
|
||||||
|
expect(keys.size).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects impossible session sizes", () => {
|
||||||
|
expect(() => generateSession(0)).toThrow();
|
||||||
|
expect(() => generateSession(145)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type {
|
||||||
|
GradeResult,
|
||||||
|
ParenthesisSignProblem,
|
||||||
|
UserAnswer,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const MIN_MULTIPLIER = -9;
|
||||||
|
const MAX_MULTIPLIER = -1;
|
||||||
|
const MIN_TERM = 1;
|
||||||
|
const MAX_TERM = 9;
|
||||||
|
const MAX_PROBLEM_COUNT = 9 * 8 * 2;
|
||||||
|
|
||||||
|
type ProblemSeed = Pick<
|
||||||
|
ParenthesisSignProblem,
|
||||||
|
"multiplier" | "a" | "operator" | "b"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function randomInt(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatexTermPair(first: number, second: number) {
|
||||||
|
const operator = second < 0 ? "-" : "+";
|
||||||
|
|
||||||
|
return `${first} ${operator} ${Math.abs(second)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProblem({ multiplier, a, operator, b }: ProblemSeed) {
|
||||||
|
const distributedA = multiplier * a;
|
||||||
|
const distributedB = operator === "-" ? multiplier * -b : multiplier * b;
|
||||||
|
const key = `${multiplier}:${a}:${operator}:${b}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `parenthesis-sign:${key}`,
|
||||||
|
key,
|
||||||
|
multiplier,
|
||||||
|
a,
|
||||||
|
operator,
|
||||||
|
b,
|
||||||
|
distributedA,
|
||||||
|
distributedB,
|
||||||
|
latexBefore: `${multiplier}\\left(${a} ${operator} ${b}\\right)`,
|
||||||
|
latexAfter: formatLatexTermPair(distributedA, distributedB),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAllProblemSeeds() {
|
||||||
|
const seeds: ProblemSeed[] = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let multiplier = MIN_MULTIPLIER;
|
||||||
|
multiplier <= MAX_MULTIPLIER;
|
||||||
|
multiplier += 1
|
||||||
|
) {
|
||||||
|
for (let a = MIN_TERM; a <= MAX_TERM; a += 1) {
|
||||||
|
for (let b = MIN_TERM; b <= MAX_TERM; b += 1) {
|
||||||
|
if (a === b) continue;
|
||||||
|
|
||||||
|
seeds.push({ multiplier, a, operator: "+", b });
|
||||||
|
seeds.push({ multiplier, a, operator: "-", b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateProblem(): ParenthesisSignProblem {
|
||||||
|
const a = randomInt(MIN_TERM, MAX_TERM);
|
||||||
|
let b = randomInt(MIN_TERM, MAX_TERM);
|
||||||
|
|
||||||
|
while (a === b) {
|
||||||
|
b = randomInt(MIN_TERM, MAX_TERM);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createProblem({
|
||||||
|
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
||||||
|
a,
|
||||||
|
operator: Math.random() < 0.5 ? "+" : "-",
|
||||||
|
b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSession(count = 10): ParenthesisSignProblem[] {
|
||||||
|
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(createAllProblemSeeds()).slice(0, count).map(createProblem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gradeAnswer(
|
||||||
|
problem: ParenthesisSignProblem,
|
||||||
|
answer: UserAnswer,
|
||||||
|
): GradeResult {
|
||||||
|
return {
|
||||||
|
correct:
|
||||||
|
answer.a === problem.distributedA && answer.b === problem.distributedB,
|
||||||
|
correctAnswer: [problem.distributedA, problem.distributedB],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface ParenthesisSignProblem {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
multiplier: number;
|
||||||
|
a: number;
|
||||||
|
operator: "+" | "-";
|
||||||
|
b: number;
|
||||||
|
distributedA: number;
|
||||||
|
distributedB: number;
|
||||||
|
latexBefore: string;
|
||||||
|
latexAfter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAnswer {
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradeResult {
|
||||||
|
correct: boolean;
|
||||||
|
correctAnswer: [number, number];
|
||||||
|
}
|
||||||
+10
-4
@@ -6,21 +6,27 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"test": "vitest run",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"katex": "^0.17.0",
|
||||||
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"react-katex": "^3.1.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/katex": "^0.16.8",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.35",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"eslint": "^8",
|
"typescript": "^5",
|
||||||
"eslint-config-next": "14.2.35"
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+696
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateSession,
|
||||||
|
gradeAnswer,
|
||||||
|
} from "@/lib/engine/parenthesis-sign";
|
||||||
|
import type {
|
||||||
|
GradeResult,
|
||||||
|
ParenthesisSignProblem,
|
||||||
|
UserAnswer,
|
||||||
|
} from "@/lib/engine/types";
|
||||||
|
|
||||||
|
interface GameSession {
|
||||||
|
problems: ParenthesisSignProblem[];
|
||||||
|
currentIndex: number;
|
||||||
|
results: (GradeResult | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
session: GameSession | null;
|
||||||
|
initSession: (count?: number) => void;
|
||||||
|
submitAnswer: (answer: UserAnswer) => void;
|
||||||
|
nextProblem: () => void;
|
||||||
|
resetSession: () => void;
|
||||||
|
currentProblem: () => ParenthesisSignProblem | null;
|
||||||
|
currentResult: () => GradeResult | null;
|
||||||
|
isComplete: () => boolean;
|
||||||
|
score: () => { correct: number; total: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SESSION_COUNT = 10;
|
||||||
|
|
||||||
|
function createSession(count = DEFAULT_SESSION_COUNT): GameSession {
|
||||||
|
const problems = generateSession(count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
problems,
|
||||||
|
currentIndex: 0,
|
||||||
|
results: problems.map(() => null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGameStore = create<GameState>((set, get) => ({
|
||||||
|
session: null,
|
||||||
|
initSession: (count = DEFAULT_SESSION_COUNT) => {
|
||||||
|
set({ session: createSession(count) });
|
||||||
|
},
|
||||||
|
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 = gradeAnswer(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() });
|
||||||
|
},
|
||||||
|
currentProblem: () => {
|
||||||
|
const { session } = get();
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
return session.problems[session.currentIndex] ?? null;
|
||||||
|
},
|
||||||
|
currentResult: () => {
|
||||||
|
const { session } = get();
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
return session.results[session.currentIndex] ?? null;
|
||||||
|
},
|
||||||
|
isComplete: () => {
|
||||||
|
const { session } = get();
|
||||||
|
|
||||||
|
return Boolean(session && session.currentIndex >= session.problems.length);
|
||||||
|
},
|
||||||
|
score: () => {
|
||||||
|
const { session } = get();
|
||||||
|
|
||||||
|
if (!session) return { correct: 0, total: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
correct: session.results.filter((result) => result?.correct).length,
|
||||||
|
total: session.problems.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
declare module "react-katex" {
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
interface MathComponentProps {
|
||||||
|
math: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineMath: ComponentType<MathComponentProps>;
|
||||||
|
export const BlockMath: ComponentType<MathComponentProps>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user