Compare commits

...

4 Commits

12 changed files with 221 additions and 17 deletions
+3
View File
@@ -85,6 +85,7 @@ prisma/
### 컴포넌트 배치
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약, 진행 단계 UI는 `components/game/` 하위에 둔다.
- 정답·오답 판정 라인처럼 피드백 화면과 결과 화면이 공유하는 표시는 `components/game/` 하위 공통 컴포넌트로 분리한다.
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
- 라우트별 조립 컴포넌트(`PlayClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
@@ -163,6 +164,8 @@ NEXTAUTH_SECRET=your-secret
- 시작 화면은 문제 수 `[5] [10]` 선택 버튼 그룹을 제공하고 기본값은 `10`이다.
- 문제 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다.
- 정답·오답 피드백은 `계산식 = 내가 입력한 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 입력값과 `×`, 굵은 정답값을 함께 보여준다.
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
---
+5 -1
View File
@@ -52,6 +52,10 @@
- 문제 수 기본값은 10개이며, `/play?count=5` 또는 `/play?count=10`으로 선택값을 전달합니다.
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
- 정답·오답 피드백은 각 항의 `계산식 = 내가 입력한 답` 형식으로 표시합니다.
- 정답 항목은 정답 색상 박스와 `○` 표시를 사용합니다.
- 오답 항목은 오답 색상 박스, 붉은색 입력값, `×`, 굵은 정답값을 함께 표시합니다.
- 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한 답` 형식으로 다시 보여주고, 정답·오답 표시 규칙을 피드백 화면과 공유합니다.
- 키보드 흐름을 지원합니다.
- 모든 항은 하나의 input group에서 순서대로 입력
- 첫째 항 Enter → 같은 input group이 둘째 항 입력으로 전환
@@ -127,7 +131,7 @@ app/ # Next.js App Router 루트
page.tsx # 시작 화면
components/
math/ # KaTeX 수식 컴포넌트
game/ # 게임 UI 컴포넌트
game/ # 게임 UI 컴포넌트, 피드백/결과 공통 표시 컴포넌트
lib/
engine/ # 문제 생성 알고리즘 엔진
prisma.ts # Prisma Client 싱글턴
+3 -1
View File
@@ -221,6 +221,8 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
<ScoreSummary
correctCount={correctCount}
totalCount={totalCount}
problems={session.problems}
results={session.results}
onRestart={resetSession}
/>
);
@@ -290,7 +292,7 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
{currentResult ? (
<FeedbackPanel
result={currentResult}
answerLatex={currentProblem.latexAfter}
problem={currentProblem}
isLastProblem={isLastProblem}
onNext={handleNextProblem}
/>
+2
View File
@@ -14,6 +14,7 @@ export default function StartControls() {
(state) => state.selectedProblemCount,
);
const setProblemCount = useGameStore((state) => state.setProblemCount);
const initSession = useGameStore((state) => state.initSession);
useEffect(() => {
startLinkRef.current?.focus();
@@ -25,6 +26,7 @@ export default function StartControls() {
<Link
ref={startLinkRef}
href={`/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"
>
+1 -2
View File
@@ -20,8 +20,7 @@ export default function Home() {
<StartControls />
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
<p> .</p>
<p> .</p>
<p> , [ ] .</p>
</div>
</section>
</main>
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import KatexRenderer from "@/components/math/KatexRenderer";
interface AnswerResultLineProps {
expressionLatex: string;
userAnswerLatex: string;
correctAnswerLatex: string;
correct: boolean;
prefix?: ReactNode;
userAnswerContent?: ReactNode;
correctAnswerContent?: ReactNode;
}
export default function AnswerResultLine({
expressionLatex,
userAnswerLatex,
correctAnswerLatex,
correct,
prefix,
userAnswerContent,
correctAnswerContent,
}: AnswerResultLineProps) {
return (
<li
className={`flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md px-4 py-3 text-lg font-semibold ring-1 ring-inset ${
correct
? "bg-emerald-100 text-emerald-950 ring-emerald-200"
: "bg-red-100 text-red-950 ring-red-200"
}`}
>
{prefix}
<KatexRenderer latex={expressionLatex} output="mathml" />
<span aria-hidden="true">=</span>
{userAnswerContent ?? (
<span className={correct ? undefined : "text-red-700"}>
<KatexRenderer latex={userAnswerLatex} output="mathml" />
</span>
)}
{correct ? (
<span
aria-label="정답"
role="img"
className="inline-flex h-7 items-center text-2xl leading-none text-emerald-700"
>
</span>
) : (
<>
<span
aria-label="오답"
role="img"
className="mr-2 inline-flex h-7 items-center text-2xl leading-none text-red-700"
>
×
</span>
<span className="text-slate-700">
(
{correctAnswerContent ?? (
<span className="font-bold">
<KatexRenderer latex={correctAnswerLatex} output="mathml" />
</span>
)}
)
</span>
</>
)}
</li>
);
}
+31 -7
View File
@@ -2,23 +2,39 @@
import { useEffect, useRef } from "react";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { GradeResult } from "@/lib/engine/types";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
interface FeedbackPanelProps {
result: GradeResult;
answerLatex: string;
problem: ParenthesisSignProblem;
isLastProblem: boolean;
onNext: () => void;
}
function formatSignedNumber(value: number) {
return value < 0 ? `(${value})` : `${value}`;
}
export default function FeedbackPanel({
result,
answerLatex,
problem,
isLastProblem,
onNext,
}: FeedbackPanelProps) {
const nextButtonRef = useRef<HTMLButtonElement>(null);
const signedTerms = [
problem.a,
problem.operator === "-" ? -problem.b : problem.b,
] as const;
const feedbackItems = signedTerms.map((term, index) => ({
formulaLatex: `${formatSignedNumber(
problem.multiplier,
)}\\times ${formatSignedNumber(term)}`,
userAnswer: result.userAnswer[index],
correctAnswer: result.correctAnswer[index],
correct: result.userAnswer[index] === result.correctAnswer[index],
}));
useEffect(() => {
nextButtonRef.current?.focus();
@@ -35,9 +51,17 @@ export default function FeedbackPanel({
<p className="text-lg font-bold">
{result.correct ? "정답입니다." : "오답입니다."}
</p>
<div className="mt-3 text-xl font-semibold">
<KatexRenderer latex={answerLatex} />
</div>
<ul className="mt-5 space-y-3">
{feedbackItems.map((item, index) => (
<AnswerResultLine
key={index}
expressionLatex={item.formulaLatex}
userAnswerLatex={`${item.userAnswer}`}
correctAnswerLatex={`${item.correctAnswer}`}
correct={item.correct}
/>
))}
</ul>
<button
ref={nextButtonRef}
type="button"
+91
View File
@@ -3,18 +3,83 @@
import Link from "next/link";
import { useEffect, useRef } from "react";
import AnswerResultLine from "@/components/game/AnswerResultLine";
import KatexRenderer from "@/components/math/KatexRenderer";
import type { GradeResult, ParenthesisSignProblem } from "@/lib/engine/types";
interface ScoreSummaryProps {
correctCount: number;
totalCount: number;
problems: ParenthesisSignProblem[];
results: (GradeResult | null)[];
onRestart: () => void;
}
function formatAnswerPairLatex(first: number, second: number) {
const operator = second < 0 ? "-" : "+";
return `${first} ${operator} ${Math.abs(second)}`;
}
function renderUserAnswerPair(result: GradeResult) {
const secondOperator = result.userAnswer[1] < 0 ? "-" : "+";
const firstCorrect = result.userAnswer[0] === result.correctAnswer[0];
const secondCorrect = result.userAnswer[1] === result.correctAnswer[1];
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={firstCorrect ? undefined : "text-red-700"}>
<KatexRenderer latex={`${result.userAnswer[0]}`} output="mathml" />
</span>
<span className={secondCorrect ? undefined : "text-red-700"}>
<KatexRenderer
latex={`${secondOperator} ${Math.abs(result.userAnswer[1])}`}
output="mathml"
/>
</span>
</span>
);
}
function renderCorrectAnswerPair(result: GradeResult) {
const secondOperator = result.correctAnswer[1] < 0 ? "-" : "+";
const firstCorrect = result.userAnswer[0] === result.correctAnswer[0];
const secondCorrect = result.userAnswer[1] === result.correctAnswer[1];
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={firstCorrect ? undefined : "font-bold text-blue-700"}>
<KatexRenderer latex={`${result.correctAnswer[0]}`} output="mathml" />
</span>
<span
className={secondCorrect ? undefined : "font-bold text-blue-700"}
>
<KatexRenderer
latex={`${secondOperator} ${Math.abs(result.correctAnswer[1])}`}
output="mathml"
/>
</span>
</span>
);
}
export default function ScoreSummary({
correctCount,
totalCount,
problems,
results,
onRestart,
}: ScoreSummaryProps) {
const restartButtonRef = useRef<HTMLButtonElement>(null);
const solvedItems = problems
.map((problem, index) => ({
problem,
result: results[index],
}))
.filter(
(item): item is { problem: ParenthesisSignProblem; result: GradeResult } =>
item.result !== null,
);
useEffect(() => {
restartButtonRef.current?.focus();
@@ -35,6 +100,32 @@ export default function ScoreSummary({
</p>
</div>
<ol className="space-y-3">
{solvedItems.map(({ problem, result }, index) => {
const userAnswerLatex = formatAnswerPairLatex(
result.userAnswer[0],
result.userAnswer[1],
);
return (
<AnswerResultLine
key={problem.key}
expressionLatex={problem.latexBefore}
userAnswerLatex={userAnswerLatex}
correctAnswerLatex={problem.latexAfter}
correct={result.correct}
userAnswerContent={renderUserAnswerPair(result)}
correctAnswerContent={renderCorrectAnswerPair(result)}
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}
+3
View File
@@ -5,14 +5,17 @@ import { renderToString } from "katex";
interface KatexRendererProps {
latex: string;
displayMode?: boolean;
output?: "htmlAndMathml" | "mathml";
}
export default function KatexRenderer({
latex,
displayMode = false,
output = "htmlAndMathml",
}: KatexRendererProps) {
const html = renderToString(latex, {
displayMode,
output,
throwOnError: false,
});
const Tag = displayMode ? "div" : "span";
+10 -6
View File
@@ -36,12 +36,16 @@ describe("parenthesis sign problem engine", () => {
it("grades correct and incorrect answers", () => {
const problem = generateProblem();
expect(
gradeAnswer(problem, {
a: problem.distributedA,
b: problem.distributedB,
}).correct,
).toBe(true);
const correctResult = gradeAnswer(problem, {
a: problem.distributedA,
b: problem.distributedB,
});
expect(correctResult.correct).toBe(true);
expect(correctResult.userAnswer).toEqual([
problem.distributedA,
problem.distributedB,
]);
expect(
gradeAnswer(problem, {
+1
View File
@@ -120,6 +120,7 @@ export function gradeAnswer(
return {
correct:
answer.a === problem.distributedA && answer.b === problem.distributedB,
userAnswer: [answer.a, answer.b],
correctAnswer: [problem.distributedA, problem.distributedB],
};
}
+1
View File
@@ -18,5 +18,6 @@ export interface UserAnswer {
export interface GradeResult {
correct: boolean;
userAnswer: [number, number];
correctAnswer: [number, number];
}