게임 로직 구현
타입과 데이터가 준비됐습니다. 이제 퀴즈가 어떻게 진행되는지 로직을 만들 차례입니다. src/engine.ts에 QuizEngine 클래스를 만들겠습니다.
QuizEngine 클래스 설계
QuizEngine은 퀴즈 진행의 모든 로직을 담는 클래스입니다. 외부에서는 "다음 문제", "답 선택", "현재 상태 조회" 같은 인터페이스만 알면 됩니다. 내부적으로 상태가 어떻게 바뀌는지는 엔진이 알아서 처리합니다.
클래스가 제공할 기능을 먼저 나열합니다.
getCurrentQuestion(): 현재 질문을 반환합니다.selectAnswer(index: number): 사용자가 선택지를 클릭했을 때 처리합니다.nextQuestion(): 다음 질문으로 넘어갑니다.getResult(): 최종 결과를 계산해서 반환합니다.reset(): 처음 상태로 되돌립니다.getState(): 현재 상태를 읽기 전용으로 반환합니다.
이것을 코드로 구현합니다.
// 새 파일: src/engine.tsimport type { Question, QuizState, QuizResult } from "./types";import { createInitialState } from "./data";export class QuizEngine { private state: QuizState; constructor() { this.state = createInitialState(); } // 현재 상태를 읽기 전용으로 반환 (외부에서 직접 수정 불가) getState(): Readonly<QuizState> { return this.state; } // 현재 질문 반환 getCurrentQuestion(): Question | null { const { questions, currentIndex } = this.state; if (currentIndex >= questions.length) return null; return questions[currentIndex]; } // 선택지 선택 — 정답 여부 판단 후 phase 변경 selectAnswer(selectedIndex: number): boolean { const question = this.getCurrentQuestion(); // 이미 답변했거나 문제가 없으면 무시 if (!question) return false; if (this.state.phase.kind !== "playing") return false; const isCorrect = question.answerIndex === selectedIndex; // selectedAnswers 업데이트 const newSelectedAnswers = [...this.state.selectedAnswers]; newSelectedAnswers[this.state.currentIndex] = selectedIndex; // 상태 변경 this.state = { ...this.state, selectedAnswers: newSelectedAnswers, phase: { kind: "answered", correct: isCorrect } }; return isCorrect; } // 다음 질문으로 이동 (마지막 질문이면 finished로 전환) nextQuestion(): void { if (this.state.phase.kind !== "answered") return; const nextIndex = this.state.currentIndex + 1; const isFinished = nextIndex >= this.state.questions.length; if (isFinished) { const score = this.calculateScore(); this.state = { ...this.state, currentIndex: nextIndex, phase: { kind: "finished", score, total: this.state.questions.length } }; } else { this.state = { ...this.state, currentIndex: nextIndex, phase: { kind: "playing" } }; } } // 점수 계산 private calculateScore(): number { return this.state.questions.reduce((score, question, index) => { const selected = this.state.selectedAnswers[index]; return selected === question.answerIndex ? score + 1 : score; }, 0); } // 결과 요약 반환 getResult(): QuizResult | null { const { phase, questions } = this.state; if (phase.kind !== "finished") return null; const { score, total } = phase; const percentage = Math.round((score / total) * 100); const grade = this.calculateGrade(percentage); return { score, total, percentage, grade }; } // 등급 계산 private calculateGrade( percentage: number ): QuizResult["grade"] { if (percentage >= 90) return "excellent"; if (percentage >= 70) return "good"; if (percentage >= 50) return "average"; return "poor"; } // 퀴즈 리셋 reset(): void { this.state = createInitialState(); }}
핵심 포인트 살펴보기
Readonly로 상태 보호
getState()의 반환 타입을 Readonly<QuizState>로 선언했습니다.
getState(): Readonly<QuizState> { return this.state;}
Readonly<T>는 TypeScript 유틸리티 타입으로, T의 모든 속성을 읽기 전용으로 만듭니다. 외부에서 engine.getState().currentIndex = 3 같은 직접 수정을 컴파일 시점에 막아줍니다. 상태 변경은 반드시 selectAnswer(), nextQuestion() 같은 메서드를 통해서만 가능합니다.
판별 유니온으로 단계 전환
phase가 판별 유니온이라서 각 단계에서 올바른 처리를 강제할 수 있습니다.
selectAnswer(selectedIndex: number): boolean { // playing 단계가 아니면 무시 if (this.state.phase.kind !== "playing") return false; // ...}nextQuestion(): void { // answered 단계가 아니면 무시 if (this.state.phase.kind !== "answered") return; // ...}
playing 상태에서만 답변을 받고, answered 상태에서만 다음으로 넘어갑니다. 버튼을 빠르게 연속으로 클릭해도 상태가 꼬이지 않습니다.
불변 상태 업데이트
상태를 변경할 때 직접 수정하지 않고 스프레드 연산자로 새 객체를 만들었습니다.
// 직접 수정 (피함)this.state.currentIndex = nextIndex;// 새 객체 생성 (권장)this.state = { ...this.state, currentIndex: nextIndex, phase: { kind: "playing" }};
이렇게 하면 상태 변경 이력을 추적하기 쉽고, 나중에 "되돌리기" 기능을 추가할 때도 수월합니다.
제네릭 유틸리티 타입 활용
QuizResult["grade"]는 인터페이스의 특정 속성 타입을 꺼내는 방법입니다.
private calculateGrade( percentage: number): QuizResult["grade"] { // "excellent" | "good" | "average" | "poor" if (percentage >= 90) return "excellent"; // ...}
"excellent" | "good" | "average" | "poor"를 직접 쓸 수도 있지만, QuizResult["grade"]로 쓰면 나중에 QuizResult에서 grade 타입을 바꿨을 때 이 함수도 자동으로 맞춰집니다.
엔진 단독 테스트
DOM 연결 전에 엔진이 올바르게 동작하는지 빠르게 확인할 수 있습니다. src/engine.ts에 임시로 테스트 코드를 붙여봅니다.
// 임시 테스트 — 확인 후 삭제const engine = new QuizEngine();console.log("첫 번째 질문:", engine.getCurrentQuestion()?.text);// 첫 번째 질문 정답(인덱스 1) 선택const correct = engine.selectAnswer(1);console.log("정답 여부:", correct); // trueengine.nextQuestion();console.log("두 번째 질문:", engine.getCurrentQuestion()?.text);// 오답 선택engine.selectAnswer(0);engine.nextQuestion();// 나머지 문제를 정답으로 처리engine.selectAnswer(1); engine.nextQuestion();engine.selectAnswer(2); engine.nextQuestion();engine.selectAnswer(3); engine.nextQuestion();const result = engine.getResult();console.log("결과:", result);// { score: 3, total: 5, percentage: 60, grade: "average" }
npx tsc && node dist/engine.js로 실행해서 결과를 확인합니다. 잘 동작한다면 테스트 코드를 지우고 다음 챕터로 넘어갑니다.
게임 로직이 완성됐습니다. 다음 챕터에서는 이 엔진을 브라우저 화면에 연결합니다.