-
Notifications
You must be signed in to change notification settings - Fork 28
Description
import React, { useEffect, useMemo, useRef, useState } from "react";
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeProblem() {
const isPlus = Math.random() < 0.5;
if (isPlus) {
const a = randomInt(0, 20);
const b = randomInt(0, 20 - a);
return { a, b, op: "+", ans: a + b };
} else {
const a = randomInt(0, 20);
const b = randomInt(0, a);
return { a, b, op: "-", ans: a - b };
}
}
function makeProblemSet(n = 20) {
const arr = [];
for (let i = 0; i < n; i++) arr.push(makeProblem());
return arr;
}
function formatTime(ms) {
const s = Math.floor(ms / 1000);
const mm = String(Math.floor(s / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return ${mm}:${ss};
}
const SCORES_KEY = "rekenrace_scores_v1"; // nieuw: lijst met scores
const LEGACY_KEY = "rekenrace_v1"; // oud: mogelijk object met bestTime/bestMargin
export default function Rekenrace() {
const TOTAL_STEPS = 20;
const [problems, setProblems] = useState(() => makeProblemSet(TOTAL_STEPS));
const [idx, setIdx] = useState(0);
const [input, setInput] = useState("");
const [catPos, setCatPos] = useState(0);
const [dogPos, setDogPos] = useState(-2);
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const [won, setWon] = useState(false);
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(Date.now());
// Scores (lijst) met veilige migratie vanaf oudere versies
const [scores, setScores] = useState(() => {
try {
const rawNew = localStorage.getItem(SCORES_KEY);
if (rawNew) {
const parsed = JSON.parse(rawNew);
return Array.isArray(parsed) ? parsed : [];
}
const rawLegacy = localStorage.getItem(LEGACY_KEY);
if (rawLegacy) {
const parsedLegacy = JSON.parse(rawLegacy);
// Als de legacy-waarde per ongeluk al een array is: gebruik die
if (Array.isArray(parsedLegacy)) return parsedLegacy;
}
} catch {}
return [];
});
const inputRef = useRef(null);
const timerRef = useRef(null);
const dogTimerRef = useRef(null);
useEffect(() => {
if (running && !done) {
timerRef.current = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timerRef.current);
}
}, [running, done]);
useEffect(() => {
if (running && !done) {
const startDelay = setTimeout(() => {
dogTimerRef.current = setInterval(() => {
setDogPos((p) => p + 1);
}, 2000);
}, 3000);
return () => {
clearTimeout(startDelay);
if (dogTimerRef.current) clearInterval(dogTimerRef.current);
};
}
}, [running, done]);
useEffect(() => {
if (!done && running && dogPos >= catPos) {
finish(false);
}tInput("");
setCatPos(0);
setDogPos(-2);
setRunning(true);
setDone(false);
setWon(false);
setStartTime(Date.now());
setNow(Date.now());
}
function stopTimers() {
if (timerRef.current) clearInterval(timerRef.current);
if (dogTimerRef.current) clearInterval(dogTimerRef.current);
}
function finish(didWin) {
stopTimers();
setRunning(false);
setDone(true);
setWon(didWin);
const endTime = Date.now();
const timeMs = startTime ? endTime - startTime : 0;
const margin = Math.max(catPos - dogPos, 0);
try {
const newScore = { id: crypto?.randomUUID?.() || String(Date.now()), date: new Date().toLocaleString(), won: didWin, timeMs, margin };
const updated = [...scores, newScore];
setScores(updated);
localStorage.setItem(SCORES_KEY, JSON.stringify(updated));
} catch {}
}
const current = problems[idx];
const elapsedMs = startTime ? now - startTime : 0;
const lead = Math.max(catPos - dogPos, 0);
function submit() {
if (!running || done) return;
const val = Number(input.trim());
if (Number.isNaN(val)) return;
const correct = current && val === current.ans;
if (correct) {
setCatPos((p) => p + 1);
const nextIdx = idx + 1;
if (nextIdx >= TOTAL_STEPS) {
setIdx(nextIdx);
setInput("");
finish(true);
} else {
setIdx(nextIdx);
setInput("");
}
} else {
const el = document.getElementById("answerbox");
if (el) {
el.classList.remove("shake");
void el.offsetWidth;
el.classList.add("shake");
}
}
}
const progressPct = (pos) => ${Math.max(0, Math.min(TOTAL_STEPS, pos)) / TOTAL_STEPS * 100}%;
// Samenvatting bovenin
const summary = useMemo(() => {
if (!scores.length) return "Nog geen scores opgeslagen";
const wins = scores.filter(s => s.won);
const bestTimeWin = wins.length ? Math.min(...wins.map(s => s.timeMs)) : null;
const bestMargin = Math.max(...scores.map(s => s.margin));
return [
Gespeeld: ${scores.length},
bestTimeWin != null ? Beste tijd (gewonnen): ${formatTime(bestTimeWin)} : null,
Grootste voorsprong: ${bestMargin} stappen,
].filter(Boolean).join(" · ");
}, [scores]);
return (
<style>{
@keyframes shake { 10%, 90% { transform: translateX(-1px); } 20%, 80% { transform: translateX(2px); } 30%, 50%, 70% { transform: translateX(-4px); } 40%, 60% { transform: translateX(4px); } } .shake { animation: shake 0.3s; }}</style>
<div className="w-full max-w-3xl">
<div className="mb-4 flex items-center justify-between">
<h1 className="text-2xl font-bold">Rekenrace: Kat 🐱 vs Hond 🐶</h1>
<div className="text-sm text-slate-600">{summary}</div>
</div>
{/* Track */}
<div className="relative w-full h-24 bg-white rounded-2xl shadow p-4">
<div className="absolute left-4 right-4 top-1/2 -translate-y-1/2 h-2 bg-slate-200 rounded-full" />
<div className="absolute left-4 top-2 text-xs text-slate-500">Start</div>
<div className="absolute right-4 top-2 text-xs text-slate-500">Finish 🏁</div>
{/* Kat */}
<div
className="absolute -translate-x-1/2 -translate-y-1/2 text-2xl transition-all duration-300"
style={{ left: `calc(1rem + ${progressPct(catPos)} * 0.92)`, top: "50%" }}
>
🐱
</div>
{/* Hond */}
<div
className="absolute -translate-x-1/2 -translate-y-1/2 text-2xl transition-all duration-500"
style={{ left: `calc(1rem + ${progressPct(dogPos)} * 0.92)`, top: "50%" }}
>
🐶
</div>
</div>
{/* HUD */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="bg-white rounded-2xl shadow p-4">
<div className="text-xs uppercase tracking-wide text-slate-500">Tijd</div>
<div className="text-2xl font-semibold">{running ? formatTime(elapsedMs) : done && startTime ? formatTime(elapsedMs) : "00:00"}</div>
</div>
<div className="bg-white rounded-2xl shadow p-4">
<div className="text-xs uppercase tracking-wide text-slate-500">Voorsprong</div>
<div className={`text-2xl font-semibold ${lead <= 2 && running ? "text-rose-600" : ""}`}>{lead} stappen</div>
</div>
</div>
{/* Problem area */}
<div className="mt-4 bg-white rounded-2xl shadow p-4">
{!running && !done && (
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Maak 20 sommen goed voordat de hond je inhaalt!</h2>
<p className="text-slate-600 text-sm mt-1">Plus en min tot en met 20. De hond start na 3 seconden en loopt 1 stap per 2 seconden.</p>
</div>
<button
className="px-4 py-2 rounded-xl bg-indigo-600 text-white shadow hover:bg-indigo-700"
onClick={start}
>
Start
</button>
</div>
)}
{running && !done && current && (
<div className="flex items-center gap-3 mt-2">
<div className="text-lg">Som {idx + 1} / {TOTAL_STEPS}</div>
<div className="flex-1" />
<div className="text-2xl font-bold tabular-nums">{current.a} {current.op} {current.b} =</div>
<input
id="answerbox"
ref={inputRef}
type="number"
inputMode="numeric"
pattern="[0-9]*"
className="w-24 text-2xl font-semibold px-3 py-1 rounded-xl border border-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
/>
<button
className="px-4 py-2 rounded-xl bg-indigo-600 text-white shadow hover:bg-indigo-700"
onClick={submit}
>
OK
</button>
</div>
)}
{done && (
<div>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">{won ? "Gewonnen! 🎉 Je bent veilig." : "Oeps! De hond heeft je gepakt."}</h2>
<p className="text-slate-600 mt-1">Tijd: {formatTime(elapsedMs)} · Voorsprong: {lead} stappen</p>
</div>
<div className="flex items-center gap-2">
<button
className="px-4 py-2 rounded-xl bg-slate-100 text-slate-800 shadow hover:bg-slate-200"
onClick={() => {
setIdx(0);
setInput("");
setCatPos(0);
setDogPos(-2);
setRunning(true);
setDone(false);
setWon(false);
setStartTime(Date.now());
setNow(Date.now());
}}
>
Nog een keer (zelfde sommen)
</button>
<button
className="px-4 py-2 rounded-xl bg-indigo-600 text-white shadow hover:bg-indigo-700"
onClick={start}
>
Nieuwe ronde
</button>
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">Eerdere scores</h3>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded-lg bg-slate-100 text-slate-800 hover:bg-slate-200 text-sm"
onClick={() => {
const sorted = scores.slice().sort((a,b)=> a.timeMs - b.timeMs);
setScores(sorted);
localStorage.setItem(SCORES_KEY, JSON.stringify(sorted));
}}
>
Sorteer op tijd
</button>
<button
className="px-3 py-1 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 text-sm"
onClick={() => {
if (confirm("Weet je zeker dat je alle scores wilt verwijderen?")) {
setScores([]);
localStorage.setItem(SCORES_KEY, JSON.stringify([]));
}
}}
>
Wis scores
</button>
</div>
</div>
<div className="max-h-48 overflow-y-auto text-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b">
<th className="py-1 pr-2">Datum</th>
<th className="py-1 pr-2">Resultaat</th>
<th className="py-1 pr-2">Tijd</th>
<th className="py-1">Voorsprong</th>
</tr>
</thead>
<tbody>
{scores.length ? (
scores.slice().reverse().map((s) => (
<tr key={s.id} className="border-b last:border-0">
<td className="py-1 pr-2 whitespace-nowrap">{s.date}</td>
<td className="py-1 pr-2">{s.won ? "Gewonnen" : "Verloren"}</td>
<td className="py-1 pr-2">{formatTime(s.timeMs)}</td>
<td className="py-1">{s.margin} stappen</td>
</tr>
))
) : (
<tr><td colSpan={4} className="text-slate-500 text-center py-2">Nog geen scores</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
{/* Uitleg */}
<div className="mt-4 bg-white rounded-2xl shadow p-4">
<details>
<summary className="cursor-pointer font-medium">Uitleg</summary>
<ul className="list-disc pl-5 mt-2 text-slate-700 text-sm">
<li>Er zijn 20 sommen (optellen en aftrekken) met uitkomsten tussen 0 en 20.</li>
<li>Elke goede som = 1 stap vooruit voor de kat 🐱.</li>
<li>De hond 🐶 start 3 seconden na de start en loopt 1 stap per 2 seconden vanzelf.</li>
<li>Bereik de finish (20 stappen) voordat de hond je inhaalt.</li>
<li>Scores worden lokaal opgeslagen op dit apparaat. Je kunt ze sorteren of wissen.</li>
</ul>
</details>
</div>
<div className="mt-4 text-center text-xs text-slate-500">© {new Date().getFullYear()} Rekenrace — lokaal opslaan via je browser</div>
</div>
</div>
);
}
}, [dogPos]);
useEffect(() => {
if (inputRef.current) inputRef.current.focus();
}, [idx, running]);
function start() {
setProblems(makeProblemSet(TOTAL_STEPS));
setIdx(0);
se