From d56cada1eca6caee35f8e3ebd5f4a8493415f693 Mon Sep 17 00:00:00 2001 From: jisangs Date: Mon, 25 May 2026 18:42:13 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81:=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 +++ app/(game)/play/PlayClient.tsx | 133 +++++++----------------------- components/game/AnswerForm.tsx | 67 +++++++++++++++ components/game/FeedbackPanel.tsx | 42 ++++++++++ components/game/ScoreSummary.tsx | 49 +++++++++++ components/math/KatexRenderer.tsx | 10 ++- lib/engine/parenthesis-sign.ts | 11 ++- package.json | 1 - pnpm-lock.yaml | 23 ------ store/gameStore.ts | 33 +------- types/react-katex.d.ts | 10 --- 11 files changed, 215 insertions(+), 175 deletions(-) create mode 100644 components/game/AnswerForm.tsx create mode 100644 components/game/FeedbackPanel.tsx create mode 100644 components/game/ScoreSummary.tsx delete mode 100644 types/react-katex.d.ts diff --git a/AGENTS.md b/AGENTS.md index 89e89ed..80caffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,8 @@ lib/ redis.ts # Redis Client 싱글턴 store/ gameStore.ts # Zustand 전역 게임 상태 +types/ + *.d.ts # 타입 선언이 없는 서드파티 모듈 보강 prisma/ schema.prisma # MariaDB 모델 정의 .gitea/workflows/deploy.yml # CI/CD 워크플로우 @@ -76,6 +78,14 @@ prisma/ - 전역 게임 상태(점수, 진행도, 선택한 모드·유형)는 `store/gameStore.ts`의 Zustand store로 관리한다. - 컴포넌트 내부의 로컬 상태는 `useState`를 사용한다. +- Zustand selector 안에서 매번 새 객체·배열을 반환하는 함수(`score()` 등)를 호출하지 않는다. 필요한 파생값은 구독한 state를 기준으로 컴포넌트 내부에서 계산하거나 안정적인 selector로 분리한다. +- store에는 세션 원본 상태와 상태 변경 action을 우선 두고, 렌더링 전용 파생값은 컴포넌트 또는 별도 순수 유틸에서 계산한다. + +### 컴포넌트 배치 + +- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약 UI는 `components/game/` 하위에 둔다. +- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다. +- 라우트별 조립 컴포넌트(`PlayClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다. ### API / 백엔드 @@ -90,6 +100,7 @@ prisma/ - 문제는 AI 호출 없이 알고리즘으로 생성한다. 외부 AI API를 문제 생성에 사용하지 않는다. - 미지수의 계수는 항상 정수이어야 한다. - 생성 파라미터: 숫자 자릿수, 괄호 깊이(`()` / `(){}` / `(){}[]`), 괄호 개수(1–3), 미지수(없음/x/x,y/x,y,z). +- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다. --- diff --git a/app/(game)/play/PlayClient.tsx b/app/(game)/play/PlayClient.tsx index 30110b2..6f87382 100644 --- a/app/(game)/play/PlayClient.tsx +++ b/app/(game)/play/PlayClient.tsx @@ -1,16 +1,20 @@ "use client"; -import Link from "next/link"; -import { FormEvent, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import AnswerForm from "@/components/game/AnswerForm"; +import FeedbackPanel from "@/components/game/FeedbackPanel"; +import ScoreSummary from "@/components/game/ScoreSummary"; import KatexRenderer from "@/components/math/KatexRenderer"; import { useGameStore } from "@/store/gameStore"; function parseAnswer(value: string) { - if (value.trim() === "") return null; + const trimmed = value.trim(); - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? null : parsed; + if (!/^-?\d+$/.test(trimmed)) return null; + + return Number(trimmed); } export default function PlayClient() { @@ -76,37 +80,11 @@ export default function PlayClient() { if (isComplete) { return ( -
-
-
-

- 괄호 음수 분배 -

-

- 풀이 결과 -

-

- {correctCount} / {totalCount} 정답 -

-
- -
- - - 처음으로 - -
-
-
+ ); } @@ -151,78 +129,23 @@ export default function PlayClient() { -
-
- - -
- - {inputError ? ( -

- {inputError} -

- ) : null} - -
- - - 처음으로 - -
-
+ /> {currentResult ? ( -
-

- {currentResult.correct ? "정답입니다." : "오답입니다."} -

-
- -
- -
+ ) : null} diff --git a/components/game/AnswerForm.tsx b/components/game/AnswerForm.tsx new file mode 100644 index 0000000..871261e --- /dev/null +++ b/components/game/AnswerForm.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { FormEvent } from "react"; + +interface AnswerFormProps { + answerA: string; + answerB: string; + disabled: boolean; + error: string; + onAnswerAChange: (value: string) => void; + onAnswerBChange: (value: string) => void; + onSubmit: (event: FormEvent) => void; +} + +export default function AnswerForm({ + answerA, + answerB, + disabled, + error, + onAnswerAChange, + onAnswerBChange, + onSubmit, +}: AnswerFormProps) { + return ( +
+
+ + +
+ + {error ? ( +

{error}

+ ) : null} + +
+ +
+
+ ); +} diff --git a/components/game/FeedbackPanel.tsx b/components/game/FeedbackPanel.tsx new file mode 100644 index 0000000..8f3272c --- /dev/null +++ b/components/game/FeedbackPanel.tsx @@ -0,0 +1,42 @@ +"use client"; + +import KatexRenderer from "@/components/math/KatexRenderer"; +import type { GradeResult } from "@/lib/engine/types"; + +interface FeedbackPanelProps { + result: GradeResult; + answerLatex: string; + isLastProblem: boolean; + onNext: () => void; +} + +export default function FeedbackPanel({ + result, + answerLatex, + isLastProblem, + onNext, +}: FeedbackPanelProps) { + return ( +
+

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

+
+ +
+ +
+ ); +} diff --git a/components/game/ScoreSummary.tsx b/components/game/ScoreSummary.tsx new file mode 100644 index 0000000..45cb27f --- /dev/null +++ b/components/game/ScoreSummary.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; + +interface ScoreSummaryProps { + correctCount: number; + totalCount: number; + onRestart: () => void; +} + +export default function ScoreSummary({ + correctCount, + totalCount, + onRestart, +}: ScoreSummaryProps) { + return ( +
+
+
+

+ 괄호 음수 분배 +

+

+ 풀이 결과 +

+

+ {correctCount} / {totalCount} 정답 +

+
+ +
+ + + 처음으로 + +
+
+
+ ); +} diff --git a/components/math/KatexRenderer.tsx b/components/math/KatexRenderer.tsx index 43788b8..18f4567 100644 --- a/components/math/KatexRenderer.tsx +++ b/components/math/KatexRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import { BlockMath, InlineMath } from "react-katex"; +import { renderToString } from "katex"; interface KatexRendererProps { latex: string; @@ -11,5 +11,11 @@ export default function KatexRenderer({ latex, displayMode = false, }: KatexRendererProps) { - return displayMode ? : ; + const html = renderToString(latex, { + displayMode, + throwOnError: false, + }); + const Tag = displayMode ? "div" : "span"; + + return ; } diff --git a/lib/engine/parenthesis-sign.ts b/lib/engine/parenthesis-sign.ts index 1db8d10..234eb90 100644 --- a/lib/engine/parenthesis-sign.ts +++ b/lib/engine/parenthesis-sign.ts @@ -25,7 +25,12 @@ function formatLatexTermPair(first: number, second: number) { return `${first} ${operator} ${Math.abs(second)}`; } -function createProblem({ multiplier, a, operator, b }: ProblemSeed) { +function createProblem({ + multiplier, + a, + operator, + b, +}: ProblemSeed): ParenthesisSignProblem { const distributedA = multiplier * a; const distributedB = operator === "-" ? multiplier * -b : multiplier * b; const key = `${multiplier}:${a}:${operator}:${b}`; @@ -65,6 +70,8 @@ function createAllProblemSeeds() { return seeds; } +const ALL_PROBLEM_SEEDS = createAllProblemSeeds(); + function shuffle(items: T[]) { const shuffled = [...items]; @@ -103,7 +110,7 @@ export function generateSession(count = 10): ParenthesisSignProblem[] { throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`); } - return shuffle(createAllProblemSeeds()).slice(0, count).map(createProblem); + return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem); } export function gradeAnswer( diff --git a/package.json b/package.json index 4bf432e..3d8997b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "next": "14.2.35", "react": "^18", "react-dom": "^18", - "react-katex": "^3.1.0", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aabfea9..481a3d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) - react-katex: - specifier: ^3.1.0 - version: 3.1.0(prop-types@15.8.1)(react@18.3.1) zustand: specifier: ^5.0.13 version: 5.0.13(@types/react@18.3.29)(react@18.3.1) @@ -1339,10 +1336,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} - hasBin: true - katex@0.17.0: resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} hasBin: true @@ -1714,12 +1707,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-katex@3.1.0: - resolution: {integrity: sha512-At9uLOkC75gwn2N+ZXc5HD8TlATsB+3Hkp9OGs6uA8tM3dwZ3Wljn74Bk3JyHFPgSnesY/EMrIAB1WJwqZqejA==} - peerDependencies: - prop-types: ^15.8.1 - react: '>=15.3.2 <20' - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3552,10 +3539,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - katex@0.16.47: - dependencies: - commander: 8.3.0 - katex@0.17.0: dependencies: commander: 8.3.0 @@ -3884,12 +3867,6 @@ snapshots: react-is@16.13.1: {} - react-katex@3.1.0(prop-types@15.8.1)(react@18.3.1): - dependencies: - katex: 0.16.47 - prop-types: 15.8.1 - react: 18.3.1 - react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/store/gameStore.ts b/store/gameStore.ts index ee866a6..344bf36 100644 --- a/store/gameStore.ts +++ b/store/gameStore.ts @@ -10,7 +10,7 @@ import type { UserAnswer, } from "@/lib/engine/types"; -interface GameSession { +export interface GameSession { problems: ParenthesisSignProblem[]; currentIndex: number; results: (GradeResult | null)[]; @@ -22,10 +22,6 @@ interface GameState { 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; @@ -79,31 +75,4 @@ export const useGameStore = create((set, get) => ({ 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, - }; - }, })); diff --git a/types/react-katex.d.ts b/types/react-katex.d.ts deleted file mode 100644 index d01bbec..0000000 --- a/types/react-katex.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare module "react-katex" { - import type { ComponentType } from "react"; - - interface MathComponentProps { - math: string; - } - - export const InlineMath: ComponentType; - export const BlockMath: ComponentType; -}