288 lines
9.0 KiB
TypeScript
288 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import { flushSync } from "react-dom";
|
|
|
|
import NumberAnswerForm from "@/components/game/NumberAnswerForm";
|
|
import type {
|
|
ActiveAnswerInput,
|
|
NumberAnswerFormHandle,
|
|
} from "@/components/game/NumberAnswerForm";
|
|
import FeedbackPanel from "@/components/game/FeedbackPanel";
|
|
import ScoreSummary from "@/components/game/ScoreSummary";
|
|
import StageIndicator from "@/components/game/StageIndicator";
|
|
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
|
import KatexRenderer from "@/components/math/KatexRenderer";
|
|
import { useGameStore } from "@/store/gameStore";
|
|
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
|
|
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
|
|
|
|
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
|
|
|
function parseAnswer(value: string) {
|
|
const trimmed = value.trim();
|
|
|
|
if (!REAL_NUMBER_PATTERN.test(trimmed)) return null;
|
|
|
|
return Number(trimmed);
|
|
}
|
|
|
|
interface NumberPlayClientProps {
|
|
problemCount: ProblemCount;
|
|
maxTermCount: MaxTermCount;
|
|
}
|
|
|
|
export default function NumberPlayClient({
|
|
problemCount,
|
|
maxTermCount,
|
|
}: NumberPlayClientProps) {
|
|
const session = useGameStore((state) => state.session);
|
|
const selectedProblemCount = useGameStore(
|
|
(state) => state.selectedProblemCount,
|
|
);
|
|
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
|
const selectedMaxTermCount = useGameStore(
|
|
(state) => state.selectedMaxTermCount,
|
|
);
|
|
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
|
|
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 problemCardRef = useRef<HTMLDivElement>(null);
|
|
const answerFormRef = useRef<NumberAnswerFormHandle>(null);
|
|
|
|
const [answers, setAnswers] = useState<string[]>(() =>
|
|
Array.from({ length: maxTermCount }, () => ""),
|
|
);
|
|
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
|
|
const [inputError, setInputError] = useState("");
|
|
|
|
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 || session?.termCount !== maxTermCount;
|
|
const correctCount =
|
|
session?.results.filter((result) => result?.correct).length ?? 0;
|
|
const totalCount = session?.problems.length ?? 0;
|
|
|
|
useEffect(() => {
|
|
if (selectedProblemCount !== problemCount) {
|
|
setProblemCount(problemCount);
|
|
}
|
|
|
|
if (selectedMaxTermCount !== maxTermCount) {
|
|
setMaxTermCount(maxTermCount);
|
|
}
|
|
|
|
if (sessionNeedsInit) {
|
|
initSession(problemCount, maxTermCount);
|
|
}
|
|
}, [
|
|
initSession,
|
|
maxTermCount,
|
|
problemCount,
|
|
selectedMaxTermCount,
|
|
selectedProblemCount,
|
|
sessionProblemCount,
|
|
sessionNeedsInit,
|
|
setMaxTermCount,
|
|
setProblemCount,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => ""));
|
|
setActiveInput(0);
|
|
setInputError("");
|
|
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
|
|
|
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
|
|
if (currentResult || !currentProblem) return;
|
|
|
|
const parsedAnswers = answers.map(parseAnswer);
|
|
|
|
if (parsedAnswers.some((answer) => answer === null)) {
|
|
setInputError("모든 항을 실수로 입력하세요.");
|
|
return;
|
|
}
|
|
|
|
setInputError("");
|
|
submitAnswer({ terms: parsedAnswers as number[] });
|
|
}
|
|
|
|
function handleAnswerChange(index: ActiveAnswerInput, value: string) {
|
|
setAnswers((currentAnswers) =>
|
|
currentAnswers.map((answer, answerIndex) =>
|
|
answerIndex === index ? value : answer,
|
|
),
|
|
);
|
|
}
|
|
|
|
function handleNextProblem() {
|
|
flushSync(() => {
|
|
nextProblem();
|
|
});
|
|
answerFormRef.current?.focusInput();
|
|
}
|
|
|
|
const scrollKatexToViewportTop = useCallback(() => {
|
|
if (window.matchMedia(DESKTOP_INPUT_DEVICE_QUERY).matches) return;
|
|
|
|
function alignKatex() {
|
|
const katexElement =
|
|
problemCardRef.current?.querySelector<HTMLElement>("span.katex");
|
|
|
|
if (!katexElement) return;
|
|
|
|
const rect = katexElement.getBoundingClientRect();
|
|
const scrollElement =
|
|
document.scrollingElement ?? document.documentElement;
|
|
const elementDocumentTop = window.scrollY + rect.top;
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
scrollElement.scrollHeight - window.innerHeight,
|
|
);
|
|
const scrollTop = Math.min(
|
|
maxScrollTop,
|
|
Math.max(0, elementDocumentTop),
|
|
);
|
|
|
|
if (Math.abs(window.scrollY - scrollTop) < 4) return;
|
|
|
|
scrollElement.scrollTop = scrollTop;
|
|
document.body.scrollTop = scrollTop;
|
|
window.scrollTo(0, scrollTop);
|
|
}
|
|
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(alignKatex);
|
|
});
|
|
}, []);
|
|
|
|
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 (
|
|
<ScoreSummary
|
|
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;
|
|
const problemLatex = submitted
|
|
? currentProblem.latexBefore
|
|
: formatParenthesisExpressionLatex({
|
|
multiplier: currentProblem.multiplier,
|
|
terms: currentProblem.terms,
|
|
operators: currentProblem.operators,
|
|
formatTermLatex: (term) => `${term}`,
|
|
activeTermIndex: activeInput,
|
|
});
|
|
const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
|
|
if (index === 0) return String(term);
|
|
|
|
return String(
|
|
currentProblem.operators[index - 1] === "-" ? -term : term,
|
|
);
|
|
});
|
|
|
|
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>
|
|
<Link
|
|
href="/number-parentheses"
|
|
onClick={resetSession}
|
|
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
>
|
|
포기
|
|
</Link>
|
|
</div>
|
|
|
|
<div
|
|
ref={problemCardRef}
|
|
className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
|
>
|
|
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
|
<KatexRenderer latex={problemLatex} displayMode />
|
|
</div>
|
|
<div className="mt-6">
|
|
<StageIndicator
|
|
currentIndex={session.currentIndex}
|
|
results={session.results}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!submitted ? (
|
|
<NumberAnswerForm
|
|
ref={answerFormRef}
|
|
answers={answers}
|
|
activeInput={activeInput}
|
|
error={inputError}
|
|
pendingAnswerLabels={pendingAnswerLabels}
|
|
onActiveInputChange={setActiveInput}
|
|
onAnswerChange={handleAnswerChange}
|
|
onInputFocus={scrollKatexToViewportTop}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
) : null}
|
|
|
|
{currentResult ? (
|
|
<FeedbackPanel
|
|
result={currentResult}
|
|
problem={currentProblem}
|
|
isLastProblem={isLastProblem}
|
|
onNext={handleNextProblem}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|