Neuste Version
This commit is contained in:
@@ -21,6 +21,15 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const handleModeSelect = (newMode) => {
|
||||
const savedMode = sessionStorage.getItem("mode");
|
||||
if (savedMode !== newMode) {
|
||||
sessionStorage.removeItem("quiz_questions");
|
||||
sessionStorage.removeItem("quiz_current_idx");
|
||||
sessionStorage.removeItem("quiz_score");
|
||||
sessionStorage.removeItem("quiz_wrong_questions");
|
||||
sessionStorage.removeItem("quiz_answer_status");
|
||||
sessionStorage.removeItem("quiz_processed");
|
||||
}
|
||||
sessionStorage.setItem("mode", newMode);
|
||||
setMode(newMode);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const COLORS = [
|
||||
"#f43f5e", "#ec4899", "#d946ef", "#a855f7", "#8b5cf6",
|
||||
"#6366f1", "#3b82f6", "#0ea5e9", "#06b6d4", "#14b8a6",
|
||||
"#10b981", "#22c55e", "#84cc16", "#eab308", "#f97316"
|
||||
];
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle"];
|
||||
|
||||
export default function Confetti({ count = 80 }) {
|
||||
const [particles, setParticles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const generated = Array.from({ length: count }).map((_, idx) => {
|
||||
const size = Math.random() * 8 + 6; // 6px - 14px
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
|
||||
const left = Math.random() * 100; // 0% - 100%
|
||||
const delay = Math.random() * 4; // 0s - 4s delay
|
||||
const duration = Math.random() * 3 + 3; // 3s - 6s duration
|
||||
const rotation = Math.random() * 360;
|
||||
|
||||
return {
|
||||
id: idx,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
left: `${left}%`,
|
||||
delay: `${delay}s`,
|
||||
duration: `${duration}s`,
|
||||
rotation: `${rotation}deg`
|
||||
};
|
||||
});
|
||||
|
||||
setParticles(generated);
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(105vh) rotate(720deg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
@keyframes confetti-sway {
|
||||
0%, 100% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(50px);
|
||||
}
|
||||
}
|
||||
.confetti-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
}
|
||||
.confetti-particle {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
animation-name: confetti-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.confetti-swayer {
|
||||
animation: confetti-sway 3s ease-in-out infinite alternate;
|
||||
}
|
||||
`}</style>
|
||||
<div className="confetti-container">
|
||||
{particles.map((p) => {
|
||||
let style = {
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.shape !== "triangle" ? p.color : "transparent",
|
||||
left: p.left,
|
||||
animationDelay: p.delay,
|
||||
animationDuration: p.duration,
|
||||
transform: `rotate(${p.rotation})`,
|
||||
};
|
||||
|
||||
if (p.shape === "circle") {
|
||||
style.borderRadius = "50%";
|
||||
} else if (p.shape === "triangle") {
|
||||
style.width = "0";
|
||||
style.height = "0";
|
||||
style.borderLeft = `${p.size / 2}px solid transparent`;
|
||||
style.borderRight = `${p.size / 2}px solid transparent`;
|
||||
style.borderBottom = `${p.size}px solid ${p.color}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="confetti-particle"
|
||||
style={style}
|
||||
>
|
||||
<div className="confetti-swayer" style={{ width: "100%", height: "100%" }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function QuestionCard({ frage, antworten, onNext, isLast }) {
|
||||
export default function QuestionCard({ frage, antworten, onNext, onCheck, isLast }) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [checked, setChecked] = useState(false); // wurde „Überprüfen“ gedrückt
|
||||
|
||||
@@ -17,7 +17,15 @@ export default function QuestionCard({ frage, antworten, onNext, isLast }) {
|
||||
const isCorrect = (idx) => antworten[idx].korrekt;
|
||||
const wasSelected = (idx) => selected.includes(idx);
|
||||
|
||||
const handleCheck = () => setChecked(true);
|
||||
const handleCheck = () => {
|
||||
setChecked(true);
|
||||
const correct = antworten.every((antwort, idx) => {
|
||||
return antwort.korrekt === selected.includes(idx);
|
||||
});
|
||||
if (onCheck) {
|
||||
onCheck(correct);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setSelected([]);
|
||||
@@ -26,7 +34,7 @@ export default function QuestionCard({ frage, antworten, onNext, isLast }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded-xl shadow-lg transition transform hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded-xl shadow-lg transition transform hover:-translate-y-1 hover:shadow-xl animate-float-in">
|
||||
<h2 className="text-lg font-semibold mb-4">{frage}</h2>
|
||||
<div className="space-y-2">
|
||||
{antworten.map((antwort, idx) => {
|
||||
|
||||
@@ -3,3 +3,18 @@
|
||||
body {
|
||||
@apply bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
@keyframes float-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float-in {
|
||||
animation: float-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,266 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getXP,
|
||||
getLevelProgress,
|
||||
getStreak,
|
||||
getUnlockedAchievements,
|
||||
getHighscores,
|
||||
ACHIEVEMENTS_LIST
|
||||
} from "../utils/gamification";
|
||||
|
||||
export default function ModeSelectPage({ onSelect }) {
|
||||
const [xp, setXp] = useState(0);
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [unlocked, setUnlocked] = useState([]);
|
||||
const [highscores, setHighscores] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("achievements"); // "achievements" | "highscores"
|
||||
|
||||
useEffect(() => {
|
||||
setXp(getXP());
|
||||
setStreak(getStreak());
|
||||
setUnlocked(getUnlockedAchievements());
|
||||
setHighscores(getHighscores());
|
||||
}, []);
|
||||
|
||||
const progress = getLevelProgress(xp);
|
||||
|
||||
const formatTime = (secs) => {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s < 10 ? "0" : ""}${s}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded shadow-md w-full max-w-md text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Wähle deinen Modus</h1>
|
||||
<p className="mb-6 text-gray-700 dark:text-gray-300">D- oder C-Lizenz?</p>
|
||||
<div className="flex flex-col gap-4 sm:flex-row justify-center">
|
||||
<button
|
||||
onClick={() => onSelect("d")}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-6 rounded transition"
|
||||
>
|
||||
D-Lizenz
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelect("c")}
|
||||
className="bg-green-600 hover:bg-green-700 text-white py-2 px-6 rounded transition"
|
||||
>
|
||||
C-Lizenz
|
||||
</button>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 py-8 px-4 sm:px-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{/* Header mit Titel und Streak */}
|
||||
<header className="flex flex-col sm:flex-row justify-between items-center bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 gap-4 transition-all">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-3xl font-extrabold bg-gradient-to-r from-blue-600 to-indigo-500 dark:from-blue-400 dark:to-indigo-300 bg-clip-text text-transparent">
|
||||
Schiri-Trainer Volleyball
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Streak Flame */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900/50 rounded-full shadow-inner animate-pulse">
|
||||
<span className="text-2xl">🔥</span>
|
||||
<div className="text-left">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wider">Lernserie</p>
|
||||
<p className="text-lg font-bold text-amber-800 dark:text-amber-300">
|
||||
{streak} {streak === 1 ? "Tag" : "Tage"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* LINKE SPALTE: Profil, Modusauswahl */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
|
||||
{/* Level & XP Card */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Erfahrungsstufe</h2>
|
||||
<p className="text-3xl font-black text-blue-600 dark:text-blue-400">Level {progress.currentLevel}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Gesamt-XP</p>
|
||||
<p className="text-lg font-extrabold">{xp} XP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="w-full bg-gray-100 dark:bg-gray-700 h-4 rounded-full overflow-hidden p-[2px] border border-gray-200/50 dark:border-gray-600/50">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 dark:from-blue-600 dark:to-indigo-500 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
<span>{progress.xpCurrentLevelStart} XP</span>
|
||||
<span>{progress.percentage}% zum nächsten Level</span>
|
||||
<span>{progress.xpNextLevelStart} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Modusauswahl Card */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold mb-4">Starte ein neues Quiz</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
{/* D-Lizenz */}
|
||||
<button
|
||||
onClick={() => onSelect("d")}
|
||||
className="group relative flex flex-col items-center justify-center p-6 bg-gradient-to-br from-blue-50 to-blue-100/30 dark:from-blue-950/20 dark:to-blue-900/10 border border-blue-200/60 dark:border-blue-800/40 rounded-2xl shadow-sm hover:shadow-md hover:border-blue-400 dark:hover:border-blue-600 text-center transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-4xl mb-3 group-hover:scale-110 transition-transform">🥉</span>
|
||||
<h3 className="text-lg font-bold text-blue-900 dark:text-blue-300">D-Lizenz</h3>
|
||||
<p className="text-xs text-blue-700/70 dark:text-blue-400/70 mt-1 max-w-[200px]">
|
||||
Grundlegende Spielregeln und Standard-Schiedsrichterentscheidungen.
|
||||
</p>
|
||||
<span className="mt-4 px-4 py-1.5 bg-blue-600 group-hover:bg-blue-700 text-white font-semibold text-xs rounded-full shadow-sm group-hover:shadow transition-all">
|
||||
Jetzt lernen
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* C-Lizenz */}
|
||||
<button
|
||||
onClick={() => onSelect("c")}
|
||||
className="group relative flex flex-col items-center justify-center p-6 bg-gradient-to-br from-emerald-50 to-emerald-100/30 dark:from-emerald-950/20 dark:to-emerald-900/10 border border-emerald-200/60 dark:border-emerald-800/40 rounded-2xl shadow-sm hover:shadow-md hover:border-emerald-400 dark:hover:border-emerald-600 text-center transition-all duration-300 transform hover:-translate-y-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-4xl mb-3 group-hover:scale-110 transition-transform">🥈</span>
|
||||
<h3 className="text-lg font-bold text-emerald-900 dark:text-emerald-300">C-Lizenz</h3>
|
||||
<p className="text-xs text-emerald-700/70 dark:text-emerald-400/70 mt-1 max-w-[200px]">
|
||||
Fortgeschrittene Regelkunde, Spezialfälle und komplexe Situationen.
|
||||
</p>
|
||||
<span className="mt-4 px-4 py-1.5 bg-emerald-600 group-hover:bg-emerald-700 text-white font-semibold text-xs rounded-full shadow-sm group-hover:shadow transition-all">
|
||||
Jetzt lernen
|
||||
</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* RECHTE SPALTE: Achievements und Highscores */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Tabs Controller */}
|
||||
<div className="bg-white dark:bg-gray-800 p-2 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("achievements")}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all cursor-pointer ${activeTab === "achievements"
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
}`}
|
||||
>
|
||||
Erfolge
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("highscores")}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all cursor-pointer ${activeTab === "highscores"
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
}`}
|
||||
>
|
||||
Bestenliste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 min-h-[300px]">
|
||||
|
||||
{/* Tab: Achievements */}
|
||||
{activeTab === "achievements" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-bold text-lg">Erfolge</h3>
|
||||
<span className="text-xs bg-gray-100 dark:bg-gray-700 px-2.5 py-1 rounded-full font-semibold">
|
||||
{unlocked.length} / {ACHIEVEMENTS_LIST.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[350px] overflow-y-auto pr-1">
|
||||
{ACHIEVEMENTS_LIST.map((ach) => {
|
||||
const isUnlocked = unlocked.includes(ach.id);
|
||||
return (
|
||||
<div
|
||||
key={ach.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${isUnlocked
|
||||
? "bg-blue-50/30 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/40"
|
||||
: "bg-gray-50/50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/30 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className={`text-2xl p-1.5 rounded-lg ${isUnlocked ? "bg-white dark:bg-gray-800 shadow-sm" : ""
|
||||
}`}>
|
||||
{ach.title.split(" ").slice(-1)[0]} {/* Nur Emoji */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-bold truncate ${isUnlocked ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400"
|
||||
}`}>
|
||||
{ach.title.split(" ").slice(0, -1).join(" ")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 line-clamp-2 leading-tight">
|
||||
{ach.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs font-bold text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40 px-2 py-0.5 rounded">
|
||||
+{ach.xpBonus} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Highscores */}
|
||||
{activeTab === "highscores" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-lg">Top 5 Versuche</h3>
|
||||
{highscores.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400 dark:text-gray-500">
|
||||
<span className="text-4xl block mb-2">🏆</span>
|
||||
<p className="text-sm">Noch keine Einträge vorhanden.</p>
|
||||
<p className="text-xs">Beende dein erstes Quiz, um hier gelistet zu werden!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{highscores.map((hs, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-6 h-6 flex items-center justify-center font-black rounded-full text-xs ${idx === 0
|
||||
? "bg-yellow-400 text-yellow-950"
|
||||
: idx === 1
|
||||
? "bg-slate-300 text-slate-800"
|
||||
: idx === 2
|
||||
? "bg-amber-600 text-amber-50"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||||
}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-sm">{hs.score}/{hs.total}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.2 rounded-full ${hs.mode === "D"
|
||||
? "bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400"
|
||||
: "bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400"
|
||||
}`}>
|
||||
{hs.mode}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">{hs.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-extrabold text-sm text-gray-700 dark:text-gray-300">{hs.percentage}%</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">⏱️ {formatTime(hs.time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,10 @@ export default function QuizPage({ mode, onBack, customQuestions }) {
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [answerStatus, setAnswerStatus] = useState([]); // "correct" | "wrong" | undefined
|
||||
const [startTime, setStartTime] = useState(null);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [isRepeatRun, setIsRepeatRun] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -46,6 +50,12 @@ useEffect(() => {
|
||||
}
|
||||
}, [mode, customQuestions, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (questions.length > 0 && !startTime) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
}, [questions, startTime]);
|
||||
|
||||
const handleAnswer = (isCorrect, currentQuestion) => {
|
||||
const index = currentIdx;
|
||||
|
||||
@@ -78,6 +88,8 @@ useEffect(() => {
|
||||
setCurrentIdx(nextIndex);
|
||||
sessionStorage.setItem("quiz_current_idx", nextIndex);
|
||||
} else {
|
||||
const duration = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0;
|
||||
setElapsedTime(duration);
|
||||
setShowResult(true);
|
||||
}
|
||||
};
|
||||
@@ -88,6 +100,7 @@ useEffect(() => {
|
||||
sessionStorage.removeItem("quiz_score");
|
||||
sessionStorage.removeItem("quiz_wrong_questions");
|
||||
sessionStorage.removeItem("quiz_answer_status");
|
||||
sessionStorage.removeItem("quiz_processed");
|
||||
};
|
||||
|
||||
if (showResult) {
|
||||
@@ -96,6 +109,9 @@ useEffect(() => {
|
||||
score={score}
|
||||
total={questions.length}
|
||||
wrongQuestions={wrongQuestions}
|
||||
timeInSeconds={elapsedTime}
|
||||
mode={mode}
|
||||
isRepeatRun={isRepeatRun}
|
||||
onRestart={() => {
|
||||
handleReset();
|
||||
setScore(0);
|
||||
@@ -104,6 +120,9 @@ useEffect(() => {
|
||||
setShowResult(false);
|
||||
setQuestions([]);
|
||||
setAnswerStatus([]);
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setIsRepeatRun(false);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}}
|
||||
onRepeatWrong={() => {
|
||||
@@ -114,6 +133,9 @@ useEffect(() => {
|
||||
setQuestions(wrongQuestions);
|
||||
setAnswerStatus([]);
|
||||
setWrongQuestions([]);
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setIsRepeatRun(true);
|
||||
}}
|
||||
onBack={onBack}
|
||||
/>
|
||||
@@ -165,6 +187,7 @@ useEffect(() => {
|
||||
</div>
|
||||
|
||||
<QuestionCard
|
||||
key={currentIdx}
|
||||
frage={currentQuestion.frage}
|
||||
antworten={currentQuestion.antworten}
|
||||
onNext={handleNext}
|
||||
|
||||
@@ -1,67 +1,220 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { addXP, updateStreak, saveHighscore, checkAchievements } from "../utils/gamification";
|
||||
import Confetti from "../components/Confetti";
|
||||
|
||||
export default function ResultPage({
|
||||
score,
|
||||
total,
|
||||
wrongQuestions,
|
||||
timeInSeconds,
|
||||
mode,
|
||||
isRepeatRun,
|
||||
onRestart,
|
||||
onRepeatWrong,
|
||||
onBack,
|
||||
}) {
|
||||
const wrongFromSession = JSON.parse(sessionStorage.getItem("quiz_wrong_questions")) || [];
|
||||
const correctCount = total - wrongFromSession.length;
|
||||
const [xpGained, setXpGained] = useState(0);
|
||||
const [newAchievements, setNewAchievements] = useState([]);
|
||||
const [isPersonalBest, setIsPersonalBest] = useState(false);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const processed = sessionStorage.getItem("quiz_processed") === "true";
|
||||
if (processed) {
|
||||
// Wenn bereits verarbeitet, berechne nur geschätzte XP für die Anzeige
|
||||
let estXp = score * 10;
|
||||
if (total >= 30 && !isRepeatRun) {
|
||||
estXp += 100;
|
||||
if (score === total) estXp += 250;
|
||||
}
|
||||
setXpGained(estXp);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Streak aktualisieren
|
||||
updateStreak();
|
||||
|
||||
// 2. Highscore speichern (nur wenn kein reines Wiederholungstraining)
|
||||
let personalBest = false;
|
||||
if (!isRepeatRun) {
|
||||
const hsRes = saveHighscore(score, total, timeInSeconds, mode);
|
||||
personalBest = hsRes.isNewPersonalBest;
|
||||
setIsPersonalBest(personalBest);
|
||||
}
|
||||
|
||||
// 3. XP berechnen
|
||||
let baseXp = score * 10;
|
||||
let completionBonus = 0;
|
||||
let perfectBonus = 0;
|
||||
|
||||
if (total >= 30 && !isRepeatRun) {
|
||||
completionBonus = 100;
|
||||
if (score === total) {
|
||||
perfectBonus = 250;
|
||||
}
|
||||
}
|
||||
|
||||
const initialGained = baseXp + completionBonus + perfectBonus;
|
||||
addXP(initialGained);
|
||||
|
||||
// 4. Achievements checken
|
||||
const unlocked = checkAchievements({
|
||||
score,
|
||||
total,
|
||||
timeInSeconds,
|
||||
mode,
|
||||
isRepeatRun
|
||||
});
|
||||
|
||||
setNewAchievements(unlocked.map((u) => u.achievement));
|
||||
|
||||
const totalXpGained = initialGained + unlocked.reduce((sum, u) => sum + u.achievement.xpBonus, 0);
|
||||
setXpGained(totalXpGained);
|
||||
|
||||
if (score === total || unlocked.length > 0) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
|
||||
sessionStorage.setItem("quiz_processed", "true");
|
||||
}, [score, total, timeInSeconds, mode, isRepeatRun]);
|
||||
|
||||
const percentage = Math.round((score / total) * 100);
|
||||
|
||||
const formatTime = (secs) => {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s < 10 ? "0" : ""}${s}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-6 rounded shadow-md max-w-md w-full text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Auswertung</h1>
|
||||
<p className="text-lg mb-6">
|
||||
Du hast <strong>{correctCount}</strong> von <strong>{total}</strong> Fragen korrekt beantwortet.
|
||||
</p>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 py-10 px-4">
|
||||
{showConfetti && <Confetti />}
|
||||
|
||||
{wrongFromSession.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||
Du hast <strong>{wrongFromSession.length}</strong> Frage(n) falsch beantwortet.
|
||||
Möchtest du sie nochmal versuchen?
|
||||
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-8 rounded-3xl shadow-xl border border-gray-100 dark:border-gray-700 max-w-lg w-full text-center space-y-6 transform transition-all duration-500 scale-100 animate-float-in">
|
||||
|
||||
{/* Titel und Trophäe */}
|
||||
<header className="space-y-2">
|
||||
{score === total ? (
|
||||
<span className="text-6xl animate-bounce inline-block">🏆</span>
|
||||
) : percentage >= 80 ? (
|
||||
<span className="text-6xl inline-block">🥈</span>
|
||||
) : (
|
||||
<span className="text-6xl inline-block">📝</span>
|
||||
)}
|
||||
<h1 className="text-3xl font-black bg-gradient-to-r from-blue-600 to-indigo-500 dark:from-blue-400 dark:to-indigo-300 bg-clip-text text-transparent">
|
||||
{score === total ? "Hervorragend!" : percentage >= 80 ? "Bestanden!" : "Gelernt!"}
|
||||
</h1>
|
||||
<p className="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500 font-bold">
|
||||
{isRepeatRun ? "Wiederholungs-Modus" : `Modus: ${mode.toUpperCase()}-Lizenz`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Ergebnisse & Progress Ring */}
|
||||
<section className="relative flex flex-col items-center py-4">
|
||||
<div className="relative w-32 h-32 flex items-center justify-center rounded-full border-8 border-gray-100 dark:border-gray-700 shadow-inner">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full border-8 border-transparent transition-all duration-1000`}
|
||||
style={{
|
||||
borderTopColor: percentage >= 80 ? "#10b981" : "#3b82f6",
|
||||
borderRightColor: percentage >= 90 ? "#10b981" : "transparent",
|
||||
transform: `rotate(${Math.min(360, (percentage / 100) * 360)}deg)`
|
||||
}}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl font-black">{percentage}%</span>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 font-bold uppercase tracking-wide">Richtig</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-lg font-bold mt-4">
|
||||
Du hast <span className="text-blue-600 dark:text-blue-400">{score}</span> von <span className="font-extrabold">{total}</span> Fragen richtig beantwortet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Gamification Stats Card */}
|
||||
<section className="bg-gray-50 dark:bg-gray-900/50 rounded-2xl p-4 border border-gray-100 dark:border-gray-800 grid grid-cols-2 gap-4 text-left">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 font-medium">⚡ Belohnung</p>
|
||||
<p className="text-lg font-black text-emerald-600 dark:text-emerald-400">
|
||||
+{xpGained} XP
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 font-medium">⏱️ Zeit</p>
|
||||
<p className="text-lg font-black">{formatTime(timeInSeconds)}</p>
|
||||
</div>
|
||||
{isPersonalBest && (
|
||||
<div className="col-span-2 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900/40 rounded-xl p-2 flex items-center justify-center gap-2 text-yellow-700 dark:text-yellow-400 text-xs font-bold animate-pulse">
|
||||
<span>👑</span> Neue persönliche Bestleistung!
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Neue Achievements */}
|
||||
{newAchievements.length > 0 && (
|
||||
<section className="space-y-2 text-left">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Erfolge freigeschaltet! 🎉</h2>
|
||||
<div className="space-y-2">
|
||||
{newAchievements.map((ach) => (
|
||||
<div
|
||||
key={ach.id}
|
||||
className="flex items-center gap-3 p-3 bg-gradient-to-r from-amber-50 to-orange-50/50 dark:from-amber-950/20 dark:to-orange-950/10 border border-amber-200 dark:border-amber-900/40 rounded-xl animate-pulse"
|
||||
>
|
||||
<span className="text-2xl">{ach.title.split(" ").slice(-1)[0]}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
|
||||
{ach.title.split(" ").slice(0, -1).join(" ")}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{ach.description}</p>
|
||||
</div>
|
||||
<span className="text-xs font-black text-amber-700 dark:text-amber-400">
|
||||
+{ach.xpBonus} XP
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Falsche Fragen & Navigation */}
|
||||
<section className="space-y-3 pt-2">
|
||||
{wrongQuestions.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={onRepeatWrong}
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded"
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
|
||||
>
|
||||
Falsche Fragen wiederholen
|
||||
Falsche Fragen wiederholen ({wrongQuestions.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
|
||||
>
|
||||
Neuer Durchlauf mit 30 Fragen
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-blue-600 underline mt-2"
|
||||
>
|
||||
Zurück zur Modusauswahl
|
||||
Neues Quiz starten (30 Fragen)
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-green-600 font-semibold">
|
||||
Mega! Du hast alle Fragen korrekt beantwortet!
|
||||
</p>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Nochmals testen mit neuen Fragen
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-blue-600 underline mt-2"
|
||||
>
|
||||
Zurück zur Modusauswahl
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/20 py-2.5 px-4 rounded-xl border border-emerald-200 dark:border-emerald-900/30">
|
||||
Perfekt! Du hast alle Fragen richtig beantwortet! 🥳
|
||||
</p>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-xl transition duration-300 shadow-md hover:shadow-lg cursor-pointer transform hover:-translate-y-0.5"
|
||||
>
|
||||
Erneut testen (neue Fragen)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:underline transition cursor-pointer mt-2 block mx-auto"
|
||||
>
|
||||
Zurück zur Startseite
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Gamification Utilities für SchiriTrainer
|
||||
// Speichert alle Spieldaten lokal im localStorage
|
||||
|
||||
const KEY_XP = "schiri_xp";
|
||||
const KEY_STREAK = "schiri_streak";
|
||||
const KEY_STREAK_LAST_DATE = "schiri_streak_last_date";
|
||||
const KEY_ACHIEVEMENTS = "schiri_achievements";
|
||||
const KEY_HIGHSCORES = "schiri_highscores";
|
||||
|
||||
export const ACHIEVEMENTS_LIST = [
|
||||
{
|
||||
id: "first_quiz",
|
||||
title: "Erster Pfiff 🎺",
|
||||
description: "Beende dein erstes Quiz.",
|
||||
xpBonus: 100,
|
||||
},
|
||||
{
|
||||
id: "perfect_score",
|
||||
title: "Fehlerfrei 🎯",
|
||||
description: "Erreiche 30/30 Punkte in einem Quiz.",
|
||||
xpBonus: 250,
|
||||
},
|
||||
{
|
||||
id: "turboschiri",
|
||||
title: "Turboschiri ⚡",
|
||||
description: "Beende ein Quiz (30 Fragen) in unter 2 Minuten.",
|
||||
xpBonus: 150,
|
||||
},
|
||||
{
|
||||
id: "d_master",
|
||||
title: "D-Lizenz Meister 🥉",
|
||||
description: "D-Lizenz Quiz mit mindestens 24 Punkten bestanden.",
|
||||
xpBonus: 100,
|
||||
},
|
||||
{
|
||||
id: "c_master",
|
||||
title: "C-Lizenz Meister 🥈",
|
||||
description: "C-Lizenz Quiz mit mindestens 24 Punkten bestanden.",
|
||||
xpBonus: 150,
|
||||
},
|
||||
{
|
||||
id: "repeat_master",
|
||||
title: "Wiederholungstäter 🔄",
|
||||
description: "Falsche Fragen wiederholt und alle korrekt beantwortet.",
|
||||
xpBonus: 100,
|
||||
},
|
||||
];
|
||||
|
||||
// --- XP & LEVEL SYSTEM ---
|
||||
|
||||
export function getXP() {
|
||||
return parseInt(localStorage.getItem(KEY_XP)) || 0;
|
||||
}
|
||||
|
||||
export function saveXP(xp) {
|
||||
localStorage.setItem(KEY_XP, xp);
|
||||
}
|
||||
|
||||
export function getLevel(xp) {
|
||||
// Level = Math.floor(Math.sqrt(xp / 100)) + 1
|
||||
return Math.floor(Math.sqrt(xp / 100)) + 1;
|
||||
}
|
||||
|
||||
export function getXPForLevel(level) {
|
||||
if (level <= 1) return 0;
|
||||
return Math.pow(level - 1, 2) * 100;
|
||||
}
|
||||
|
||||
export function getLevelProgress(xp) {
|
||||
const currentLevel = getLevel(xp);
|
||||
const xpCurrentLevelStart = getXPForLevel(currentLevel);
|
||||
const xpNextLevelStart = getXPForLevel(currentLevel + 1);
|
||||
const needed = xpNextLevelStart - xpCurrentLevelStart;
|
||||
const earned = xp - xpCurrentLevelStart;
|
||||
const percentage = Math.min(100, Math.max(0, (earned / needed) * 100));
|
||||
|
||||
return {
|
||||
currentLevel,
|
||||
nextLevel: currentLevel + 1,
|
||||
xpCurrentLevelStart,
|
||||
xpNextLevelStart,
|
||||
needed,
|
||||
earned,
|
||||
percentage: Math.round(percentage),
|
||||
};
|
||||
}
|
||||
|
||||
export function addXP(amount) {
|
||||
const currentXP = getXP();
|
||||
const oldLevel = getLevel(currentXP);
|
||||
const newXP = currentXP + amount;
|
||||
saveXP(newXP);
|
||||
const newLevel = getLevel(newXP);
|
||||
|
||||
return {
|
||||
newXP,
|
||||
oldLevel,
|
||||
newLevel,
|
||||
leveledUp: newLevel > oldLevel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- DAILY STREAK ---
|
||||
|
||||
export function getStreak() {
|
||||
const streak = parseInt(localStorage.getItem(KEY_STREAK)) || 0;
|
||||
const lastDateStr = localStorage.getItem(KEY_STREAK_LAST_DATE);
|
||||
|
||||
if (!lastDateStr) return 0;
|
||||
|
||||
const today = getTodayString();
|
||||
const yesterday = getYesterdayString();
|
||||
|
||||
// Wenn der letzte Eintrag heute oder gestern war, ist der Streak noch gültig.
|
||||
// Sonst ist er abgelaufen (0).
|
||||
if (lastDateStr === today || lastDateStr === yesterday) {
|
||||
return streak;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function updateStreak() {
|
||||
const today = getTodayString();
|
||||
const yesterday = getYesterdayString();
|
||||
const lastDateStr = localStorage.getItem(KEY_STREAK_LAST_DATE);
|
||||
let streak = parseInt(localStorage.getItem(KEY_STREAK)) || 0;
|
||||
|
||||
if (lastDateStr === today) {
|
||||
// Heute bereits gelernt, Streak bleibt gleich
|
||||
return streak;
|
||||
} else if (lastDateStr === yesterday) {
|
||||
// Gestern gelernt -> Streak erhöhen!
|
||||
streak += 1;
|
||||
} else {
|
||||
// Letztes Mal war länger her (oder erster Eintrag) -> Auf 1 setzen
|
||||
streak = 1;
|
||||
}
|
||||
|
||||
localStorage.setItem(KEY_STREAK, streak);
|
||||
localStorage.setItem(KEY_STREAK_LAST_DATE, today);
|
||||
return streak;
|
||||
}
|
||||
|
||||
// --- ACHIEVEMENTS ---
|
||||
|
||||
export function getUnlockedAchievements() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY_ACHIEVEMENTS)) || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function unlockAchievement(id) {
|
||||
const unlocked = getUnlockedAchievements();
|
||||
if (unlocked.includes(id)) return null; // bereits freigeschaltet
|
||||
|
||||
const ach = ACHIEVEMENTS_LIST.find((a) => a.id === id);
|
||||
if (!ach) return null;
|
||||
|
||||
unlocked.push(id);
|
||||
localStorage.setItem(KEY_ACHIEVEMENTS, JSON.stringify(unlocked));
|
||||
|
||||
// XP Bonus gutschreiben
|
||||
const levelUpResult = addXP(ach.xpBonus);
|
||||
|
||||
return {
|
||||
achievement: ach,
|
||||
...levelUpResult,
|
||||
};
|
||||
}
|
||||
|
||||
// Prüft und schaltet Achievements frei basierend auf den aktuellen Quiz-Daten
|
||||
export function checkAchievements(context) {
|
||||
const { score, total, timeInSeconds, mode, isRepeatRun } = context;
|
||||
const newlyUnlocked = [];
|
||||
|
||||
// 1. Erster Pfiff (Erstes beendetes Quiz)
|
||||
const unlocked = getUnlockedAchievements();
|
||||
if (!unlocked.includes("first_quiz") && !isRepeatRun) {
|
||||
const res = unlockAchievement("first_quiz");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
// 2. Fehlerfrei (30/30)
|
||||
if (score === 30 && total === 30) {
|
||||
const res = unlockAchievement("perfect_score");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
// 3. Turboschiri (Beendet in unter 120s und vollzählig 30 Fragen)
|
||||
if (timeInSeconds < 120 && total === 30 && !isRepeatRun) {
|
||||
const res = unlockAchievement("turboschiri");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
// 4. D-Lizenz Meister
|
||||
if (mode === "d" && score >= 24 && total === 30 && !isRepeatRun) {
|
||||
const res = unlockAchievement("d_master");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
// 5. C-Lizenz Meister
|
||||
if (mode === "c" && score >= 24 && total === 30 && !isRepeatRun) {
|
||||
const res = unlockAchievement("c_master");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
// 6. Wiederholungstäter (Falsche Fragen wiederholt und alle gelöst)
|
||||
if (isRepeatRun && score === total && total > 0) {
|
||||
const res = unlockAchievement("repeat_master");
|
||||
if (res) newlyUnlocked.push(res);
|
||||
}
|
||||
|
||||
return newlyUnlocked;
|
||||
}
|
||||
|
||||
// --- HIGHSCORES ---
|
||||
|
||||
export function getHighscores() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY_HIGHSCORES)) || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHighscore(score, total, timeInSeconds, mode) {
|
||||
const scores = getHighscores();
|
||||
const percentage = (score / total) * 100;
|
||||
|
||||
const newEntry = {
|
||||
score,
|
||||
total,
|
||||
percentage: Math.round(percentage * 10) / 10,
|
||||
time: timeInSeconds,
|
||||
mode: mode.toUpperCase(),
|
||||
date: new Date().toLocaleDateString("de-DE"),
|
||||
};
|
||||
|
||||
scores.push(newEntry);
|
||||
|
||||
// Sortiere nach Prozent absteigend, bei Gleichstand nach Zeit aufsteigend
|
||||
scores.sort((a, b) => {
|
||||
if (b.percentage !== a.percentage) {
|
||||
return b.percentage - a.percentage;
|
||||
}
|
||||
return a.time - b.time;
|
||||
});
|
||||
|
||||
// Nur die Top 5 behalten
|
||||
const topScores = scores.slice(0, 5);
|
||||
localStorage.setItem(KEY_HIGHSCORES, JSON.stringify(topScores));
|
||||
|
||||
// Prüfen, ob der neue Eintrag in den Top 5 gelandet ist
|
||||
const isTopScore = topScores.some(
|
||||
(s) => s.date === newEntry.date && s.score === newEntry.score && s.time === newEntry.time
|
||||
);
|
||||
|
||||
return {
|
||||
highscores: topScores,
|
||||
isNewPersonalBest: isTopScore,
|
||||
};
|
||||
}
|
||||
|
||||
// --- HELPER ---
|
||||
|
||||
function getTodayString() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getYesterdayString() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user