퀴즈 앱
객체 배열과 상태 관리, 타이머를 다룹니다.
무엇을 만드나
문제를 하나씩 내고, 답을 고르면 다음으로 넘어가고, 끝나면 점수를 보여 주는 화면을 만듭니다.
화면에 있을 것
- 몇 번째 문제인지와 남은 시간
- 문제와 보기 단추들
- 끝났을 때의 점수 화면
지켜야 할 규칙
- 한 문제에 20초를 주고, 넘기면 틀린 것으로 넘어갑니다.
- 고른 뒤에는 다시 고를 수 없습니다.
- 마지막 문제를 마치면 점수 화면으로 바뀝니다.
이 프로젝트의 핵심은 상태를 한곳에 모으는 것입니다. 지금 몇 번째인지, 몇 개 맞혔는지, 이미 골랐는지를 흩어 두면 금방 어긋납니다.
1단계 — 문제와 상태를 정합니다
const QUESTIONS = [ { text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 }, { text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 } ]; // 지금 상태를 한 객체에 모읍니다 const state = { index: 0, score: 0, answered: false }; console.log(QUESTIONS.length); console.log(QUESTIONS[state.index].text); console.log(QUESTIONS[state.index].choices.length);
answer 는 보기의 번호입니다. 글자로 견주면
보기 문구를 고칠 때마다 답도 고쳐야 합니다.
2단계 — 문제 하나 그리기
const QUESTIONS = [ { text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 }, { text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 } ]; const state = { index: 0, score: 0, answered: false }; const progress = document.querySelector("#progress"); const question = document.querySelector("#question"); const choices = document.querySelector("#choices"); function render() { const current = QUESTIONS[state.index]; progress.textContent = `${state.index + 1} / ${QUESTIONS.length}`; question.textContent = current.text; choices.replaceChildren(); current.choices.forEach((choice, i) => { const btn = document.createElement("button"); btn.textContent = choice; btn.dataset.index = i; choices.append(btn); }); } render(); console.log(progress.textContent); console.log(choices.children.length);
8.1 과 같은 방식입니다 — 상태를 보고 화면을 그리는 함수 하나를 두고, 바뀔 때마다 그것만 다시 부릅니다.
3단계 — 답을 고르고 넘어가기
const QUESTIONS = [ { text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 }, { text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 } ]; const state = { index: 0, score: 0, answered: false }; const question = document.querySelector("#question"); const choices = document.querySelector("#choices"); const result = document.querySelector("#result"); function render() { const current = QUESTIONS[state.index]; question.textContent = current.text; choices.replaceChildren(); current.choices.forEach((choice, i) => { const btn = document.createElement("button"); btn.textContent = choice; btn.dataset.index = i; choices.append(btn); }); } function answer(picked) { if (state.answered) return; state.answered = true; const current = QUESTIONS[state.index]; const correct = picked === current.answer; if (correct) state.score++; choices.children[picked].classList.add(correct ? "right" : "wrong"); result.textContent = correct ? "맞았습니다" : "틀렸습니다"; } choices.addEventListener("click", event => { const btn = event.target.closest("button"); if (!btn) return; answer(Number(btn.dataset.index)); }); render(); // 첫 보기를 두 번 눌러 봅니다 choices.children[0].click(); choices.children[0].click(); console.log(state.score); console.log(state.answered); console.log(result.textContent);
두 번 눌렀는데 점수는 1 입니다.
state.answered 를 맨 앞에서 확인해
두 번째는 그 자리에서 끝냈습니다(7.3).
4단계 — 시간 재기
setInterval 로 1초마다 줄이고, 0이 되면 넘깁니다.
넘어갈 때 반드시 멈춰야 합니다 — 안 그러면 여러 개가 겹쳐 돕니다(5.1).
const timer = document.querySelector("#timer"); let left = 3; let ticking = null; function stop() { if (ticking !== null) { clearInterval(ticking); ticking = null; } } function start() { stop(); // 먼저 멈춰야 겹치지 않습니다 left = 3; timer.textContent = `${left}초`; ticking = setInterval(() => { left--; timer.textContent = `${left}초`; if (left <= 0) { stop(); timer.textContent = "시간이 끝났습니다"; } }, 100); } start(); // 끝날 때까지 기다렸다가 확인합니다 await new Promise(resolve => setTimeout(resolve, 500)); console.log(timer.textContent); console.log(ticking);
연습이라 100 밀리초로 줄였습니다. 실제로는
1000 을 넣습니다.
ticking 이 null 이라는
것은 멈췄다는 뜻입니다. 담아 두지 않으면 멈출 방법이 없습니다.
모두 합치기
const QUESTIONS = [ { text: "배열의 첫 값을 꺼내는 번호는?", choices: ["0", "1", "-1"], answer: 0 }, { text: "값과 타입을 함께 비교하는 것은?", choices: ["==", "===", "="], answer: 1 }, { text: "코드를 한 번만 실행하려면?", choices: ["setInterval", "setTimeout", "for"], answer: 1 } ]; const SECONDS = 20; const quiz = document.querySelector("#quiz"); const progress = document.querySelector("#progress"); const timer = document.querySelector("#timer"); const question = document.querySelector("#question"); const choices = document.querySelector("#choices"); const score = document.querySelector("#score"); const state = { index: 0, score: 0, answered: false, left: SECONDS }; let ticking = null; function stopTimer() { if (ticking !== null) { clearInterval(ticking); ticking = null; } } function startTimer() { stopTimer(); state.left = SECONDS; timer.textContent = `${state.left}초`; ticking = setInterval(() => { state.left--; timer.textContent = `${state.left}초`; if (state.left <= 0) next(); }, 1000); } function render() { const current = QUESTIONS[state.index]; state.answered = false; progress.textContent = `${state.index + 1} / ${QUESTIONS.length}`; question.textContent = current.text; choices.replaceChildren(); current.choices.forEach((choice, i) => { const btn = document.createElement("button"); btn.textContent = choice; btn.dataset.index = i; choices.append(btn); }); startTimer(); } function finish() { stopTimer(); quiz.classList.add("hidden"); score.classList.remove("hidden"); score.textContent = `${QUESTIONS.length}문제 가운데 ${state.score}개를 맞혔습니다`; } function next() { stopTimer(); if (state.index >= QUESTIONS.length - 1) { finish(); return; } state.index++; render(); } choices.addEventListener("click", event => { const btn = event.target.closest("button"); if (!btn || state.answered) return; state.answered = true; stopTimer(); const picked = Number(btn.dataset.index); const correct = picked === QUESTIONS[state.index].answer; if (correct) state.score++; btn.classList.add(correct ? "right" : "wrong"); setTimeout(next, 600); }); render(); // 확인용으로 첫 문제의 정답을 고릅니다 choices.children[0].click(); console.log(state.score); console.log(state.answered);
stopTimer 를 세 곳에서 부릅니다 — 답을
골랐을 때, 다음으로 넘어갈 때, 끝났을 때. 한 곳이라도 빠뜨리면 타이머가 겹쳐
점수가 어긋납니다.
미리보기 탭에서 끝까지 풀어 보세요. 답을 고르면 0.6초 뒤 다음 문제로 넘어갑니다.
더 해 볼 것
시작할 때 문제 차례를 무작위로 섞으세요.
slice() 로 복사한 뒤 sort(() => Math.random() - 0.5) 로 섞습니다(3.5). 원본을 바꾸지 않는 것이 중요합니다.지금까지의 최고 점수를 남기고 점수 화면에 함께 보여 주세요.
localStorage 에 담습니다(7.4). 꺼낸 값은 글자이므로 Number 로 바꿔 견주고, 처음이면 0 으로 둡니다.끝난 뒤 틀린 문제만 모아 다시 푸는 단추를 두세요.
state.picked 배열을 두고 번호를 넣습니다. 끝나면 filter 로 틀린 것만 모아 새 문제 목록으로 다시 시작합니다(3.3).이 프로젝트에서 배운 것
- 지금 상태를 한 객체에 모읍니다. 흩어 두면 금방 어긋납니다.
- 정답은 글자가 아니라 번호로 담습니다.
- 같은 일이 두 번 일어나지 않게 맨 앞에서 걸러 냅니다.
- 타이머는 담아 두어야 멈출 수 있습니다. 넘어갈 때마다 먼저 멈춥니다.
- 화면 전환은 class 를 켜고 끄는 것으로 합니다(4.4).