This commit is contained in:
schmop 2026-04-28 01:54:27 +02:00
commit 68e097dbf5
49 changed files with 9859 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
dist/
node_modules
.claude/settings.local.json

198
BRAINSTORM.md Normal file
View File

@ -0,0 +1,198 @@
# Projektbeschreibung
Ich möchte eine Lern-App für Grundschulkinder entwickeln, die Zahlzerlegungen im Zahlenraum bis 10 trainiert. Die App ist speziell für Kinder gedacht, die größtenteils noch nicht lesen können. Deshalb muss die Bedienung selbsterklärlich, visuell und textarm sein.
Der Fokus liegt nicht auf allgemeinen Rechenarten, sondern ausschließlich auf Zahlzerlegung.
## Zielgruppe
Kinder im Grundschulalter, insbesondere Schulanfänger.
Wichtig ist:
* sehr große, gut erkennbare Bedienelemente
* kaum Text
* intuitive Bedienung per Fingertipp
* positives, motivierendes Feedback
* keine Vermenschlichung der Objekte, aber comicartige Gestaltung
## Lerninhalt
Trainiert werden Zahlzerlegungen für die Zielzahlen 4 bis 10.
### Beispiel:
* Zielzahl: 7
* Vorgegebene erste Zerlegungszahl: 2
* Das Kind muss die passende Ergänzung finden: 5
Die App soll es ermöglichen, eine Zielzahl gezielt auszuwählen und nur diese zu üben.
## Grundidee des Spiels
Das Spiel ist thematisch als Raketenflug gestaltet.
Die Rakete startet unten und steigt bei richtigen Antworten immer weiter nach oben. Die Höhenstufen sind:
1. Boden / Start
2. Luftballon
3. Wolken
4. Mond
5. Sterne
6. Regenbogenland
Das Kind soll innerhalb einer vorgegebenen Zeit möglichst weit kommen.
Wenn es das Regenbogenland vor Ablauf der Zeit erreicht, endet die Runde sofort erfolgreich.
# Spielmechanik
Oben auf dem Bildschirm ist die Zielzahl groß und prominent sichtbar.
Darunter wird jeweils eine erste Zerlegungszahl vorgegeben.
Das Kind muss aus mehreren angebotenen Zahlen die passende Ergänzungszahl antippen.
### Beispiel:
* Zielzahl 7
* Vorgegebene Zahl 2
* Das Kind tippt 5
Danach erscheint direkt die nächste Aufgabe.
## Zeitmechanik
Die Aufgaben laufen auf Zeit.
Wichtig:
* Die verfügbare Zeit soll pro Kind individuell einstellbar sein.
* Die letzten 5 Sekunden sollen sanft akustisch markiert werden.
* Wenn die Zeit abläuft, endet die Runde sofort.
* Wenn das Kind vorher das Regenbogenland erreicht, endet die Runde ebenfalls sofort.
## Levelsystem
Die Rakete steigt nicht nach jeder einzelnen richtigen Antwort sofort eine ganze Stufe auf.
Stattdessen braucht es mehrere richtige Antworten pro Stufe.
Festes Standardmodell:
* Stufe 1: nach 2 richtigen Aufgaben
* Stufe 2: nach weiteren 2 richtigen Aufgaben
* Stufe 3: nach weiteren 3 richtigen Aufgaben
* Stufe 4: nach weiteren 3 richtigen Aufgaben
* Stufe 5 / Regenbogenland: Ziel erreicht
Insgesamt also 10 richtige Aufgaben, um das Endziel zu erreichen.
## Feedback
Das Feedback soll positiv und direkt im Spiel integriert sein.
Bei richtiger Antwort:
* die Rakete erhält sichtbar einen Schub
* positiver Sound
* kleine Animation
* Fortschritt auf der Höhenanzeige
Bei falscher Antwort:
* keine negative Bestrafung
* kein Knall, kein Absturz, kein Zerplatzen
* nur sanfte Rückmeldung und direkt nächste Chance
## Darstellung / Design
Der Stil soll
* comicartig
* freundlich
* bunt
* klar
* nicht überladen
sein.
Wichtig:
* die Rakete soll nicht vermenschlicht sein
* keine Gesichter auf Raketen oder Objekten
* trotzdem kindgerechte, attraktive Gestaltung
* auch für Mädchen ansprechend, ohne stark geschlechtertypische Voreinstellungen
## Fortschrittssystem für Kinder
Für jede Zielzahl soll sichtbar sein, wie gut das Kind sie schon beherrscht.
Die Fortschrittsanzeige soll bildlich über die Raketenhöhe funktionieren:
* Luftballon
* Wolken
* Mond
* Sterne
* Regenbogenland
Die Idee ist:
Wenn ein Kind bei einer Zielzahl immer wieder bis ins Regenbogenland kommt, dann beherrscht es diese Zielzahl gut.
### Highscore-System
Es soll pro Zielzahl einen eigenen Highscore geben.
Beispiel:
Ein Kind hat für die Zielzahl 7 seine eigenen besten Ergebnisse gespeichert.
Wichtig:
* nicht textlastig
* eher bildlich
* Vergleich mit sich selbst, nicht mit anderen Kindern
Gewünscht ist eine vertikale Highscore-Anzeige, damit das Prinzip „höher = besser“ sichtbar bleibt.
Mögliche Darstellung:
* die 5 besten bisherigen Flüge als kleine Raketen übereinander
* höchste Leistung oben
* aktuelle Runde markiert
* beste Leistung mit Krone oder Symbol
Für jedes Kind soll pro Zielzahl sichtbar sein:
* wie oft geübt wurde
* wie weit das Kind typischerweise kommt
* ob die Zielzahl eher unsicher oder sicher wirkt
Diese Übersicht soll schlicht und symbolbasiert sein.
Keine langen Texte, keine Notizfunktion nötig.
Sound
Gewünscht sind dezente Soundeffekte für:
* Antippen
* richtige Antwort
* Raketenschub
* Erreichen einer neuen Stufe
* letzte 5 Sekunden vor Ablauf
Wichtig:
* keine Sprachansagen nötig
* Sounds müssen im Menü abschaltbar sein
Wichtige didaktische Prinzipien
* wenig Text
* keine Lesekompetenz voraussetzen
* klare Symbole
* große Touchflächen
* positives Feedback statt Bestrafung
* Motivation durch sichtbaren Fortschritt
* kindgerechte Wiederholung ohne Überforderung
Wichtig ist vor allem, dass die pädagogische Logik und die einfache Bedienung erhalten bleiben.
Kurzfassung in einem Satz
Die App soll eine visuell intuitive, motivierende Lern-App für Grundschulkinder sein, in der Kinder Zahlzerlegungen von 4 bis 10 auf Zeit üben, ohne lesen zu müssen, mit positivem Feedback und symbolischem Fortschrittssystem.

77
CLAUDE.md Normal file
View File

@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Touch-bedienbare Lern-PWA für Grundschulkinder (Schulanfänger, **kein Lesen vorausgesetzt**). Kinder üben Zahlzerlegungen für Zielzahlen 410 auf Zeit, thematisch als Raketenflug. Detaillierte Produktspezifikation in `BRAINSTORM.md` — bei UI-/UX-Entscheidungen dort zuerst nachsehen.
Kernprinzipien aus `BRAINSTORM.md`, die bei jeder Änderung beachtet werden müssen:
- **Kein Text** in der Spielfläche — nur Symbole, Zahlen, Animationen.
- **Keine Vermenschlichung** (keine Gesichter auf Rakete/Objekten).
- **Positives Feedback only** — keine Bestrafung bei falschen Antworten, kein Knall/Absturz.
- **Große Touchflächen** (mind. 8496 px), `user-select: none`, `touch-action: manipulation`.
- **Sounds müssen abschaltbar** sein (global via Settings-Store).
## Commands
```bash
npm run dev # Vite-Dev-Server auf :5173
npm run build # Production-Build (Vite + vite-plugin-pwa)
npm run preview # Build lokal servieren
npm run check # svelte-check (Type-Check)
npm test # Vitest einmal ausführen
npm run test:watch
npx vitest run path/to/foo.test.ts # einzelne Test-Datei
```
Visuelle Verifikation mit `firefox-devtools`-MCP: Firefox via `swaymsg` floating + 1980×1200 stellen, dann `navigate_page` + `screenshot_page`. Beispiele für relevante Viewport-Größen: 1920×1080, 1280×720, 390×844.
## Architecture
### Tech-Stack
Svelte 5 (Runes-Mode: `$props()`, `$state()`, `$derived()`, `$effect()`) + Vite 6 + TypeScript (strict) + `vite-plugin-pwa`. Vitest mit jsdom-Environment. **Kein Backend** — alle Daten in `localStorage`.
### Drei-Schichten-Trennung in `src/lib/`
```
game/ — Pure Logic, framework-frei, Vitest-getestet
stores/ — Svelte-Stores; halten Reactive-State, syncen zu localStorage
components/, screens/, audio/ — UI; konsumieren Stores, lösen Aktionen aus
```
Diese Trennung ist load-bearing: **Logik nie in Komponenten**, **Storage-Zugriffe nur in `game/persistence.ts`**, **State-Mutations gehen über Store-Setter** (`startGame`, `answer`, `setRoundSeconds` …) statt direkter `.set()`-Aufrufe.
### State-Flow
`route` (writable) entscheidet, welcher Screen rendert (App.svelte switched per `if`). `game` ist der Spielzustand, wird per `startGame(target)` initialisiert und tickt selbst über `requestAnimationFrame` (`game.ts:tick`). Bei Antwort → `answer(value)` → ggf. `correctCount++`, neue Aufgabe via `generateTask`, `stageBumpKey` inkrementiert (FX-Trigger). Bei `correctCount === 10` oder Timer = 0 → `finalize()` schreibt in `progress`-Store, der via `subscribe`-Hook automatisch in `localStorage` persistiert.
`settings` und `progress` laden initial aus `localStorage` (mit Schema-Toleranz in `loadProgress`/`loadSettings`) und schreiben automatisch bei Änderungen zurück.
### Stufen-Mapping (Game-Design-Subtilität)
`stages.ts:stageFor(correct)` mappt 0..1→0 (Boden), 2..3→1 (Luftballon), 4..6→2 (Wolken), 7..9→3 (Mond), 10+→4 (Sterne). Stufe 5 (Regenbogenland) ist **kein** stageFor-Rückgabewert, sondern die Win-Celebration im ResultScreen. `BRAINSTORM.md` listet 5 Stufen aber nur 4 Schwellen (2/4/7/10) — Sterne und Regenbogenland sind im Endspiel zusammengefasst.
### Sound
`soundManager.ts` synthetisiert alle Sounds **generativ via WebAudio** (Oszillatoren + Noise-Bursts) — **keine Audio-Asset-Dateien** im Projekt. Mute global über `settings.soundOn`. Browser-Autoplay-Policy: `unlockAudio()` im ersten User-Tap aufrufen (HomeScreen, ResultScreen).
### PWA
`vite-plugin-pwa` mit `registerType: 'autoUpdate'` generiert Service Worker und Manifest. Icons in `public/icons/` werden per `rsvg-convert` aus `favicon.svg` gerendert (192/512). Bei Manifest-Änderungen muss SW neu generiert werden (`npm run build`).
### Test-Setup
`src/test-setup.ts` shimmt `localStorage`, weil **Node 25 ein kaputtes experimentelles `localStorage` mitbringt** (es überschreibt jsdoms Implementation, aber `clear` etc. fehlen). Der Setup-File ersetzt es vor jedem Test mit einer in-memory-Implementierung. Wenn neue jsdom-abhängige Globals nötig sind, dort hinzufügen.
## Styling-Konventionen
- Farben aus CSS-Variablen in `src/app.css` (`--c-*`). Nicht hartcodieren.
- Karten/Komponenten mit `aspect-ratio` brauchen Höhen-Constraints am Container, sonst Overflow auf breiten Schirmen — siehe `HomeScreen.svelte:.cards-grid` für die Formel `C·3·(H(R1)·g) / (4·R) + (C1)·g`.
- `100dvh` statt `100vh` (mobile Browser-Chrome).
## Pitfalls
- **`isolatedModules: true`** in tsconfig: Type-Imports brauchen `import type`.
- **Svelte 5 Runes**: Nicht mit `let`-reactivity (alter Svelte 4-Stil) mischen. `$effect` kann Cleanup-Function returnieren.
- **`stageBumpKey`** als Trigger-Mechanismus: nur inkrementieren, wenn Stufe **wirklich** wechselt (nicht bei jeder richtigen Antwort) — siehe `game.ts:answer`.

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="theme-color" content="#1a2c5c" />
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg" />
<title>Zahlzerlegung</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

7398
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "zahlzerlegung",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"jsdom": "^25.0.1",
"svelte": "^5.20.2",
"svelte-check": "^4.1.4",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.5"
}
}

7
public/icons/favicon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#1a2c5c" />
<path d="M22 84 L36 67 L36 94 Z" fill="#4a4a6e" />
<path d="M78 84 L64 67 L64 94 Z" fill="#4a4a6e" />
<path d="M50 8 Q72 28 72 66 L72 92 Q60 96 50 96 Q40 96 28 92 L28 66 Q28 28 50 8 Z" fill="#ff6b6b" stroke="#a83a3a" stroke-width="2"/>
<circle cx="50" cy="46" r="11" fill="#ffe566" stroke="#c2a230" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 456 B

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

27
src/App.svelte Normal file
View File

@ -0,0 +1,27 @@
<script lang="ts">
import { route } from './lib/stores/route';
import HomeScreen from './lib/screens/HomeScreen.svelte';
import GameScreen from './lib/screens/GameScreen.svelte';
import ResultScreen from './lib/screens/ResultScreen.svelte';
import SettingsScreen from './lib/screens/SettingsScreen.svelte';
</script>
<main>
{#if $route.name === 'home'}
<HomeScreen />
{:else if $route.name === 'game'}
<GameScreen target={$route.target} />
{:else if $route.name === 'result'}
<ResultScreen target={$route.target} />
{:else if $route.name === 'settings'}
<SettingsScreen />
{/if}
</main>
<style>
main {
width: 100%;
height: 100%;
position: relative;
}
</style>

61
src/app.css Normal file
View File

@ -0,0 +1,61 @@
:root {
/* Farbpalette: warm, kindgerecht, kontrastreich */
--c-sky-top: #1a2c5c;
--c-sky-bottom: #6fb3ff;
--c-ground: #6cc26c;
--c-cloud: #f5f9ff;
--c-rocket-body: #ff6b6b;
--c-rocket-window: #ffe566;
--c-rocket-fin: #4a4a6e;
--c-flame-inner: #fff4b3;
--c-flame-outer: #ff8a3d;
--c-rainbow-1: #ff5555;
--c-rainbow-2: #ffa84a;
--c-rainbow-3: #ffe34a;
--c-rainbow-4: #6cdc6c;
--c-rainbow-5: #4ab3ff;
--c-rainbow-6: #b06cff;
--c-correct: #5fd07a;
--c-text: #1f1f3a;
--c-text-on-dark: #ffffff;
--shadow-soft: 0 4px 16px rgba(0, 0, 0, 0.18);
--radius-card: 24px;
--radius-pill: 999px;
--tap-min: 96px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
overscroll-behavior: none;
}
body {
background: linear-gradient(to bottom, var(--c-sky-top) 0%, var(--c-sky-bottom) 100%);
color: var(--c-text);
overflow: hidden;
}
button {
font-family: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
color: inherit;
}
button:focus-visible {
outline: 4px solid var(--c-rocket-window);
outline-offset: 4px;
}

View File

@ -0,0 +1,103 @@
// Generative WebAudio-Sounds — keine externen Dateien nötig, klein und kindgerecht.
// Mute global über Settings-Store.
//
// Sound-Events:
// tap — Antwort-Tipp (kurzer Klick)
// correct — richtige Antwort (heller Ding)
// boost — Raketenschub (Whoosh, niedrig)
// level — neue Stufe (Akkord aufsteigend)
// countdown — letzte 5 Sekunden (Tick pro Sekunde, höher werdend)
// fanfare — Regenbogenland erreicht (kurze Tonfolge)
import { get } from 'svelte/store';
import { settings } from '../stores/settings';
export type SoundName = 'tap' | 'correct' | 'boost' | 'level' | 'countdown' | 'fanfare';
let ctx: AudioContext | null = null;
function getCtx(): AudioContext | null {
if (typeof window === 'undefined') return null;
if (!ctx) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
ctx = new Ctor();
}
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
return ctx;
}
function tone(freq: number, durationMs: number, type: OscillatorType = 'sine', startGain = 0.18): Promise<void> {
const c = getCtx();
if (!c) return Promise.resolve();
return new Promise((resolve) => {
const osc = c.createOscillator();
const gain = c.createGain();
osc.type = type;
osc.frequency.value = freq;
gain.gain.setValueAtTime(startGain, c.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, c.currentTime + durationMs / 1000);
osc.connect(gain).connect(c.destination);
osc.start();
osc.stop(c.currentTime + durationMs / 1000);
osc.onended = () => resolve();
});
}
function noiseBurst(durationMs: number, startGain = 0.18, filterFreq = 800): void {
const c = getCtx();
if (!c) return;
const buffer = c.createBuffer(1, c.sampleRate * (durationMs / 1000), c.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
const src = c.createBufferSource();
src.buffer = buffer;
const filt = c.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = filterFreq;
const gain = c.createGain();
gain.gain.value = startGain;
src.connect(filt).connect(gain).connect(c.destination);
src.start();
}
export function play(name: SoundName): void {
if (!get(settings).soundOn) return;
switch (name) {
case 'tap':
void tone(880, 60, 'square', 0.06);
break;
case 'correct':
void tone(660, 100, 'sine', 0.18).then(() => tone(990, 140, 'sine', 0.18));
break;
case 'boost':
noiseBurst(220, 0.14, 1200);
break;
case 'level': {
const c = getCtx();
if (!c) return;
const notes = [523.25, 659.25, 783.99]; // C5 E5 G5
notes.forEach((f, i) => {
setTimeout(() => void tone(f, 220, 'triangle', 0.15), i * 90);
});
break;
}
case 'countdown':
void tone(1200, 80, 'square', 0.12);
break;
case 'fanfare': {
const seq = [523.25, 659.25, 783.99, 1046.5];
seq.forEach((f, i) => {
setTimeout(() => void tone(f, 260, 'triangle', 0.18), i * 130);
});
break;
}
}
}
// Erlaubt manuelles Aufwecken bei erstem User-Tap (Browser-Policy).
export function unlockAudio(): void {
getCtx();
}

View File

@ -0,0 +1,91 @@
<script lang="ts">
type Props = {
choices: number[];
correctAnswer: number;
disabled?: boolean;
onAnswer: (value: number) => void;
};
let { choices, correctAnswer, disabled = false, onAnswer }: Props = $props();
let wrongValue = $state<number | null>(null);
let lockedCorrect = $state<number | null>(null);
function handle(value: number) {
if (disabled || lockedCorrect !== null) return;
if (value === correctAnswer) {
lockedCorrect = value;
onAnswer(value);
// beim nächsten Aufgabenwechsel via $effect wieder zurücksetzen
} else {
wrongValue = value;
onAnswer(value);
setTimeout(() => {
if (wrongValue === value) wrongValue = null;
}, 380);
}
}
// Reset bei Aufgabenwechsel
$effect(() => {
void choices; // dependency
lockedCorrect = null;
wrongValue = null;
});
</script>
<div class="answers" role="group" aria-label="Antwortmöglichkeiten">
{#each choices as value (value)}
<button
type="button"
class="answer"
class:wrong={wrongValue === value}
class:correct={lockedCorrect === value}
onclick={() => handle(value)}
aria-label={`Antwort ${value}`}
{disabled}
>
{value}
</button>
{/each}
</div>
<style>
.answers {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
}
.answer {
min-width: var(--tap-min);
min-height: var(--tap-min);
padding: 0 18px;
font-size: clamp(48px, 8vw, 84px);
font-weight: 900;
background: var(--c-cloud);
color: var(--c-text);
border-radius: 22px;
box-shadow: 0 6px 0 #b9c4dc;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.08s ease;
}
.answer:active:not(:disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 #b9c4dc;
}
.answer.wrong {
animation: shake 0.36s ease;
background: #ffd0d0;
}
.answer.correct {
background: var(--c-correct);
color: white;
transform: scale(1.06);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts">
type Props = { triggerKey: number };
let { triggerKey }: Props = $props();
// Pro Trigger erzeugen wir 14 Partikel mit zufälligen Winkeln/Distanzen.
let particles = $state<Array<{ id: number; angle: number; distance: number; color: string; size: number }>>([]);
let active = $state(false);
let counter = 0;
const PALETTE = ['#ff5555', '#ffa84a', '#ffe34a', '#6cdc6c', '#4ab3ff', '#b06cff'];
$effect(() => {
if (triggerKey === 0) return;
counter++;
const seed = counter;
particles = Array.from({ length: 16 }, (_, i) => ({
id: seed * 100 + i,
angle: (i / 16) * 360 + (Math.random() - 0.5) * 22,
distance: 80 + Math.random() * 60,
color: PALETTE[i % PALETTE.length],
size: 8 + Math.random() * 8,
}));
active = true;
const timeout = setTimeout(() => {
active = false;
}, 700);
return () => clearTimeout(timeout);
});
</script>
{#if active}
<div class="burst" aria-hidden="true">
{#each particles as p (p.id)}
<span
class="particle"
style="
--angle: {p.angle}deg;
--distance: {p.distance}px;
--color: {p.color};
width: {p.size}px;
height: {p.size}px;
"
></span>
{/each}
</div>
{/if}
<style>
.burst {
position: absolute;
inset: 0;
pointer-events: none;
display: grid;
place-items: center;
}
.particle {
position: absolute;
border-radius: 50%;
background: var(--color);
transform: rotate(var(--angle)) translateY(0);
animation: fly 0.7s ease-out forwards;
}
@keyframes fly {
0% { transform: rotate(var(--angle)) translateY(0) scale(0.6); opacity: 1; }
100% { transform: rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(1); opacity: 0; }
}
</style>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import type { Stage } from '../../game/stages';
import Rocket from '../svg/Rocket.svelte';
import Balloon from '../svg/Balloon.svelte';
import Cloud from '../svg/Cloud.svelte';
import Moon from '../svg/Moon.svelte';
import Star from '../svg/Star.svelte';
import Rainbow from '../svg/Rainbow.svelte';
type Props = { stage: Stage; won: boolean };
let { stage, won }: Props = $props();
// Höhenpositionen 0..1 entlang der Bahn (von unten = 0 nach oben = 1)
const STAGE_Y = [0.05, 0.25, 0.45, 0.65, 0.85, 0.97] as const;
const rocketY = $derived(won ? STAGE_Y[5] : STAGE_Y[stage]);
</script>
<div class="track" class:won>
<div class="layer ground"></div>
<div class="layer sky-low"></div>
<div class="layer sky-mid"></div>
<div class="layer sky-high"></div>
<div class="layer space"></div>
<!-- Markierungen -->
<div class="marker" style="bottom: {STAGE_Y[1] * 100}%"><Balloon size={56} /></div>
<div class="marker" style="bottom: {STAGE_Y[2] * 100}%; left: 70%"><Cloud size={64} opacity={0.95} /></div>
<div class="marker" style="bottom: {STAGE_Y[3] * 100}%; left: 30%"><Moon size={56} /></div>
<div class="marker stars" style="bottom: {STAGE_Y[4] * 100}%">
<Star size={28} />
<Star size={20} color="#fff" />
<Star size={24} color="#a8e8ff" />
</div>
<div class="marker rainbow" style="bottom: {STAGE_Y[5] * 100}%" class:visible={won}>
<Rainbow size={140} />
</div>
<div class="rocket" style="bottom: calc({rocketY * 100}% - 60px)" aria-hidden="true">
<Rocket size={120} />
</div>
</div>
<style>
.track {
position: relative;
width: 100%;
height: 100%;
border-radius: 18px;
overflow: hidden;
background: var(--c-sky-top);
}
.layer {
position: absolute;
left: 0;
right: 0;
}
.ground { bottom: 0; height: 8%; background: linear-gradient(to top, #4ea34e, var(--c-ground)); }
.sky-low { bottom: 8%; height: 22%; background: linear-gradient(to top, #b6e3ff, #6fb3ff); }
.sky-mid { bottom: 30%; height: 30%; background: linear-gradient(to top, #6fb3ff, #4a7ec4); }
.sky-high { bottom: 60%; height: 25%; background: linear-gradient(to top, #4a7ec4, #2a3f7a); }
.space { bottom: 85%; height: 15%; background: linear-gradient(to top, #2a3f7a, #0a0e2a); }
.marker {
position: absolute;
left: 18%;
transform: translate(-50%, 50%);
pointer-events: none;
opacity: 0.85;
}
.marker.stars { display: flex; gap: 6px; left: 60%; }
.rainbow {
left: 50%;
transform: translate(-50%, 50%);
opacity: 0;
transition: opacity 0.6s ease, transform 0.6s ease;
}
.rainbow.visible {
opacity: 1;
transform: translate(-50%, 50%) scale(1.1);
}
.rocket {
position: absolute;
left: 50%;
transform: translateX(-50%);
transition: bottom 0.6s cubic-bezier(0.2, 0.9, 0.2, 1);
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.35));
}
.track.won .rocket {
transition: bottom 1.4s cubic-bezier(0.18, 0.7, 0.2, 1);
}
</style>

View File

@ -0,0 +1,64 @@
<script lang="ts">
import type { Task } from '../../game/tasks';
type Props = { task: Task };
let { task }: Props = $props();
</script>
<div class="prompt" aria-label={`${task.target} ist ${task.given} und welche Zahl?`}>
<div class="target">
<span class="num target-num">{task.target}</span>
</div>
<div class="equation">
<span class="num given">{task.given}</span>
<span class="plus">+</span>
<span class="placeholder" aria-hidden="true">?</span>
</div>
</div>
<style>
.prompt {
display: grid;
grid-template-rows: auto auto;
gap: 8px;
align-items: center;
text-align: center;
color: var(--c-text-on-dark);
}
.target-num {
font-size: clamp(72px, 14vw, 140px);
font-weight: 900;
text-shadow: 0 4px 0 rgba(0,0,0,0.18);
}
.target { line-height: 0.9; }
.equation {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
font-weight: 800;
}
.num {
font-size: clamp(40px, 7vw, 72px);
background: var(--c-cloud);
color: var(--c-text);
border-radius: 18px;
padding: 6px 22px;
box-shadow: 0 4px 0 rgba(0,0,0,0.18);
min-width: 72px;
display: inline-block;
}
.plus {
font-size: clamp(40px, 6vw, 56px);
color: var(--c-rocket-window);
}
.placeholder {
font-size: clamp(40px, 7vw, 72px);
background: rgba(255, 255, 255, 0.18);
color: var(--c-rocket-window);
border: 4px dashed var(--c-rocket-window);
border-radius: 18px;
padding: 6px 22px;
min-width: 72px;
display: inline-block;
}
</style>

View File

@ -0,0 +1,49 @@
<script lang="ts">
type Props = { secondsLeft: number; totalSeconds: number };
let { secondsLeft, totalSeconds }: Props = $props();
const ratio = $derived(Math.max(0, Math.min(1, secondsLeft / totalSeconds)));
const C = 2 * Math.PI * 28; // Umfang
const dashoffset = $derived(C * (1 - ratio));
const warning = $derived(secondsLeft <= 5);
</script>
<div class="timer" class:warning aria-label={`${secondsLeft} Sekunden übrig`}>
<svg width="72" height="72" viewBox="0 0 72 72">
<circle cx="36" cy="36" r="28" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="6" />
<circle
cx="36"
cy="36"
r="28"
fill="none"
stroke="var(--c-rocket-window)"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray={C}
stroke-dashoffset={dashoffset}
transform="rotate(-90 36 36)"
/>
</svg>
<span class="num">{secondsLeft}</span>
</div>
<style>
.timer {
position: relative;
width: 72px;
height: 72px;
display: grid;
place-items: center;
}
.timer svg { position: absolute; inset: 0; }
.num {
font-size: 24px;
font-weight: 800;
color: var(--c-text-on-dark);
}
.timer.warning .num { color: #ff7373; animation: pulse 0.8s ease infinite; }
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.18); }
}
</style>

View File

@ -0,0 +1,79 @@
<script lang="ts">
import type { Target } from '../../game/tasks';
import { progress } from '../../stores/progress';
import Rocket from '../svg/Rocket.svelte';
import Crown from '../svg/Crown.svelte';
type Props = { target: Target; highlightDate?: string | null };
let { target, highlightDate = null }: Props = $props();
const top5 = $derived($progress.perTarget[target]?.top5 ?? []);
// Slots immer 5 lang darstellen — leere Plätze bleiben grau.
const slots = $derived(Array.from({ length: 5 }, (_, i) => top5[i] ?? null));
</script>
<div class="column" aria-label="Beste Flüge">
{#each slots as slot, i (i)}
<div class="slot" class:empty={!slot} class:current={slot && highlightDate && slot.date === highlightDate}>
{#if slot}
{#if i === 0}
<span class="crown"><Crown size={28} /></span>
{/if}
<Rocket size={44} flameAnimated={false} />
<span class="stage-label" aria-label={`Stufe ${slot.stage}`}>
{#each Array(slot.stage) as _, dotIndex (dotIndex)}
<span class="stage-dot"></span>
{/each}
</span>
{:else}
<div class="placeholder"></div>
{/if}
</div>
{/each}
</div>
<style>
.column {
display: grid;
grid-template-rows: repeat(5, 1fr);
gap: 10px;
height: 100%;
}
.slot {
position: relative;
background: rgba(255, 255, 255, 0.16);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 8px;
min-height: 56px;
}
.slot.empty {
background: rgba(255, 255, 255, 0.06);
}
.slot.current {
background: rgba(255, 229, 102, 0.3);
box-shadow: 0 0 0 3px var(--c-rocket-window);
}
.crown {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
}
.stage-label { display: flex; gap: 3px; }
.stage-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-rocket-window);
}
.placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px dashed rgba(255, 255, 255, 0.3);
}
</style>

View File

@ -0,0 +1,107 @@
<script lang="ts">
import type { Target } from '../../game/tasks';
import { progress } from '../../stores/progress';
import Rocket from '../svg/Rocket.svelte';
import Crown from '../svg/Crown.svelte';
type Props = { target: Target; onClick: (t: Target) => void };
let { target, onClick }: Props = $props();
const bestStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
const runs = $derived($progress.perTarget[target]?.runs ?? 0);
// Fortschritt 0..1 → Höhe der Mini-Rakete in der Karte
const progressFraction = $derived(Math.min(bestStage / 5, 1));
const masteredAtRainbow = $derived(bestStage >= 5);
</script>
<button class="card" type="button" onclick={() => onClick(target)} aria-label={`Zielzahl ${target}`}>
<div class="number">{target}</div>
<div class="track" aria-hidden="true">
<div class="rocket-wrap" style="bottom: calc({progressFraction * 100}% - 18px)">
<Rocket size={36} flameAnimated={false} />
</div>
<div class="dot dot-1"></div>
<div class="dot dot-2"></div>
<div class="dot dot-3"></div>
<div class="dot dot-4"></div>
<div class="dot dot-5"></div>
</div>
<div class="footer">
{#if masteredAtRainbow}
<Crown size={28} />
{/if}
<span class="runs" aria-label={`${runs} Runden gespielt`}>
{#each Array(Math.min(runs, 5)) as _, i (i)}
<span class="run-dot"></span>
{/each}
</span>
</div>
</button>
<style>
.card {
background: linear-gradient(160deg, #ffffff 0%, #e7eefb 100%);
border-radius: var(--radius-card);
box-shadow: var(--shadow-soft);
aspect-ratio: 3 / 4;
min-width: 0;
padding: 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 6px;
transition: transform 0.12s ease;
}
.card:active { transform: scale(0.96); }
.number {
font-size: clamp(48px, 8vw, 80px);
font-weight: 800;
color: var(--c-text);
text-align: center;
line-height: 1;
}
.track {
position: relative;
background: linear-gradient(to top, #6cc26c 0%, #6cc26c 8%, #b6e3ff 30%, #6fb3ff 70%, #1a2c5c 100%);
border-radius: 12px;
overflow: hidden;
}
.rocket-wrap {
position: absolute;
left: 50%;
transform: translateX(-50%);
transition: bottom 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dot {
position: absolute;
left: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
}
.dot-1 { bottom: 18%; }
.dot-2 { bottom: 38%; }
.dot-3 { bottom: 58%; }
.dot-4 { bottom: 78%; }
.dot-5 { bottom: 92%; background: var(--c-rainbow-3); width: 6px; height: 6px; }
.footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 4px;
}
.runs { display: flex; gap: 3px; }
.run-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--c-rocket-fin);
opacity: 0.5;
}
</style>

View File

@ -0,0 +1,30 @@
<script lang="ts">
type Props = {
onClick: () => void;
label: string;
children: import('svelte').Snippet;
};
let { onClick, label, children }: Props = $props();
</script>
<button class="icon-button" type="button" aria-label={label} onclick={onClick}>
{@render children()}
</button>
<style>
.icon-button {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
color: var(--c-text-on-dark);
transition: transform 0.12s ease, background 0.12s ease;
}
.icon-button:active {
transform: scale(0.92);
background: rgba(255, 255, 255, 0.32);
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { settings, toggleSound } from '../../stores/settings';
</script>
<button
class="sound-toggle"
type="button"
aria-label={$settings.soundOn ? 'Ton aus' : 'Ton an'}
onclick={toggleSound}
>
{#if $settings.soundOn}
<svg width="32" height="32" viewBox="0 0 32 32" aria-hidden="true">
<path d="M5 12 L12 12 L20 6 L20 26 L12 20 L5 20 Z" fill="currentColor" />
<path d="M23 10 Q28 16 23 22" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M26 6 Q33 16 26 26" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" />
</svg>
{:else}
<svg width="32" height="32" viewBox="0 0 32 32" aria-hidden="true">
<path d="M5 12 L12 12 L20 6 L20 26 L12 20 L5 20 Z" fill="currentColor" />
<path d="M22 10 L30 22 M30 10 L22 22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
{/if}
</button>
<style>
.sound-toggle {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: var(--c-text-on-dark);
display: grid;
place-items: center;
transition: transform 0.12s ease;
}
.sound-toggle:active { transform: scale(0.92); }
</style>

View File

@ -0,0 +1,10 @@
<script lang="ts">
let { size = 80 }: { size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 80 110" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="40" cy="42" rx="30" ry="36" fill="#ff8aa8" stroke="#c25578" stroke-width="2" />
<ellipse cx="30" cy="32" rx="8" ry="14" fill="#ffd0db" opacity="0.7" />
<path d="M36 78 L44 78 L42 84 L38 84 Z" fill="#c25578" />
<path d="M40 84 Q38 95 42 105" stroke="#5c3344" stroke-width="1.5" fill="none" />
</svg>

View File

@ -0,0 +1,12 @@
<script lang="ts">
let { size = 100, opacity = 1 }: { size?: number; opacity?: number } = $props();
</script>
<svg width={size} height={size * 0.6} viewBox="0 0 120 70" xmlns="http://www.w3.org/2000/svg" {opacity} aria-hidden="true">
<g fill="var(--c-cloud)" stroke="#cad6e8" stroke-width="2">
<ellipse cx="30" cy="42" rx="22" ry="18" />
<ellipse cx="60" cy="32" rx="28" ry="22" />
<ellipse cx="90" cy="42" rx="22" ry="18" />
<rect x="22" y="42" width="76" height="18" rx="9" />
</g>
</svg>

View File

@ -0,0 +1,17 @@
<script lang="ts">
let { size = 36 }: { size?: number } = $props();
</script>
<svg width={size} height={size * 0.75} viewBox="0 0 48 36" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M4 30 L8 8 L18 20 L24 4 L30 20 L40 8 L44 30 Z"
fill="#ffd84a"
stroke="#c2a230"
stroke-width="2"
stroke-linejoin="round"
/>
<rect x="4" y="28" width="40" height="6" rx="1.5" fill="#c2a230" />
<circle cx="12" cy="14" r="2" fill="#ff5555" />
<circle cx="24" cy="12" r="2.5" fill="#4ab3ff" />
<circle cx="36" cy="14" r="2" fill="#5fd07a" />
</svg>

View File

@ -0,0 +1,11 @@
<script lang="ts">
let { size = 90 }: { size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="50" cy="50" r="40" fill="#ffe9a3" stroke="#c2a230" stroke-width="2" />
<circle cx="40" cy="42" r="6" fill="#e6cf7d" opacity="0.6" />
<circle cx="62" cy="56" r="8" fill="#e6cf7d" opacity="0.6" />
<circle cx="56" cy="36" r="3" fill="#e6cf7d" opacity="0.6" />
<circle cx="35" cy="62" r="4" fill="#e6cf7d" opacity="0.6" />
</svg>

View File

@ -0,0 +1,14 @@
<script lang="ts">
let { size = 200 }: { size?: number } = $props();
</script>
<svg width={size} height={size * 0.6} viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="none" stroke-width="14" stroke-linecap="round">
<path d="M20 110 A80 80 0 0 1 180 110" stroke="var(--c-rainbow-1)" />
<path d="M34 110 A66 66 0 0 1 166 110" stroke="var(--c-rainbow-2)" />
<path d="M48 110 A52 52 0 0 1 152 110" stroke="var(--c-rainbow-3)" />
<path d="M62 110 A38 38 0 0 1 138 110" stroke="var(--c-rainbow-4)" />
<path d="M76 110 A24 24 0 0 1 124 110" stroke="var(--c-rainbow-5)" />
<path d="M90 110 A10 10 0 0 1 110 110" stroke="var(--c-rainbow-6)" />
</g>
</svg>

View File

@ -0,0 +1,50 @@
<script lang="ts">
type Props = { size?: number; flameAnimated?: boolean };
let { size = 120, flameAnimated = true }: Props = $props();
</script>
<svg width={size} height={size} viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Flamme: 2 Pfade, größer + kleiner, sanft pulsierend -->
<g class="flame" class:flame-animated={flameAnimated}>
<path
class="flame-outer"
d="M35 110 Q50 145 65 110 Q60 125 50 132 Q40 125 35 110 Z"
fill="var(--c-flame-outer)"
/>
<path
class="flame-inner"
d="M42 110 Q50 130 58 110 Q55 120 50 124 Q45 120 42 110 Z"
fill="var(--c-flame-inner)"
/>
</g>
<!-- Finnen -->
<path d="M22 92 L36 75 L36 105 Z" fill="var(--c-rocket-fin)" />
<path d="M78 92 L64 75 L64 105 Z" fill="var(--c-rocket-fin)" />
<!-- Rumpf -->
<path
d="M50 8 Q72 30 72 70 L72 105 Q60 110 50 110 Q40 110 28 105 L28 70 Q28 30 50 8 Z"
fill="var(--c-rocket-body)"
stroke="#a83a3a"
stroke-width="2"
/>
<!-- Fenster -->
<circle cx="50" cy="50" r="11" fill="var(--c-rocket-window)" stroke="#c2a230" stroke-width="2" />
<circle cx="46" cy="46" r="3" fill="#fff" opacity="0.7" />
<!-- Bandage am Rumpf -->
<rect x="28" y="78" width="44" height="6" fill="#c2a230" rx="2" />
</svg>
<style>
.flame-animated {
transform-origin: 50px 110px;
animation: flicker 0.32s steps(2) infinite;
}
@keyframes flicker {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.78); }
}
</style>

View File

@ -0,0 +1,13 @@
<script lang="ts">
let { size = 40, color = '#ffe34a' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<polygon
points="20,3 24,15 37,15 27,23 31,36 20,28 9,36 13,23 3,15 16,15"
fill={color}
stroke="#c2a230"
stroke-width="1.5"
stroke-linejoin="round"
/>
</svg>

View File

@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
loadProgress,
saveProgress,
recordRun,
emptyProgress,
loadSettings,
saveSettings,
DEFAULT_SETTINGS,
} from './persistence';
describe('progress', () => {
it('returns empty progress when nothing stored', () => {
const p = loadProgress();
expect(p.perTarget[7].runs).toBe(0);
expect(p.perTarget[7].top5).toEqual([]);
});
it('round-trips saved progress', () => {
const p = recordRun(emptyProgress(), 7, 4);
saveProgress(p);
const loaded = loadProgress();
expect(loaded.perTarget[7].runs).toBe(1);
expect(loaded.perTarget[7].top5[0].stage).toBe(4);
});
it('keeps top5 sorted by stage descending', () => {
let p = emptyProgress();
p = recordRun(p, 5, 2);
p = recordRun(p, 5, 4);
p = recordRun(p, 5, 1);
p = recordRun(p, 5, 3);
const stages = p.perTarget[5].top5.map((r) => r.stage);
expect(stages).toEqual([4, 3, 2, 1]);
});
it('limits top5 to five entries', () => {
let p = emptyProgress();
for (let i = 0; i < 10; i++) p = recordRun(p, 6, ((i % 5) + 1) as 1 | 2 | 3 | 4 | 5);
expect(p.perTarget[6].top5.length).toBe(5);
expect(p.perTarget[6].runs).toBe(10);
});
it('tolerates corrupt JSON in storage', () => {
localStorage.setItem('zahlzerlegung.progress', '{not json');
const p = loadProgress();
expect(p.perTarget[8].runs).toBe(0);
});
it('tolerates partial schema', () => {
localStorage.setItem(
'zahlzerlegung.progress',
JSON.stringify({ perTarget: { 7: { runs: 3 } } })
);
const p = loadProgress();
expect(p.perTarget[7].runs).toBe(3);
expect(p.perTarget[7].top5).toEqual([]);
expect(p.perTarget[5].runs).toBe(0);
});
});
describe('settings', () => {
it('returns defaults when nothing stored', () => {
const s = loadSettings();
expect(s.roundSeconds).toBe(DEFAULT_SETTINGS.roundSeconds);
expect(s.soundOn).toBe(DEFAULT_SETTINGS.soundOn);
});
it('round-trips settings', () => {
saveSettings({ schemaVersion: 1, roundSeconds: 90, soundOn: false });
const s = loadSettings();
expect(s.roundSeconds).toBe(90);
expect(s.soundOn).toBe(false);
});
it('falls back to defaults on invalid roundSeconds', () => {
localStorage.setItem(
'zahlzerlegung.settings',
JSON.stringify({ roundSeconds: -1, soundOn: 'yes' })
);
const s = loadSettings();
expect(s.roundSeconds).toBe(DEFAULT_SETTINGS.roundSeconds);
expect(s.soundOn).toBe(DEFAULT_SETTINGS.soundOn);
});
});

119
src/lib/game/persistence.ts Normal file
View File

@ -0,0 +1,119 @@
import type { Stage } from './stages';
import type { Target } from './tasks';
import { TARGETS } from './tasks';
const SCHEMA_VERSION = 1;
const PROGRESS_KEY = 'zahlzerlegung.progress';
const SETTINGS_KEY = 'zahlzerlegung.settings';
export type RunRecord = { stage: Stage; date: string };
export type TargetProgress = {
runs: number;
top5: RunRecord[]; // sortiert: höchste Stufe zuerst, bei Gleichstand neueste zuerst
};
export type Progress = {
schemaVersion: number;
perTarget: Record<Target, TargetProgress>;
};
export type Settings = {
schemaVersion: number;
roundSeconds: number;
soundOn: boolean;
};
export const DEFAULT_SETTINGS: Settings = {
schemaVersion: SCHEMA_VERSION,
roundSeconds: 60,
soundOn: true,
};
export function emptyProgress(): Progress {
const perTarget = {} as Record<Target, TargetProgress>;
for (const t of TARGETS) perTarget[t] = { runs: 0, top5: [] };
return { schemaVersion: SCHEMA_VERSION, perTarget };
}
function readJson<T>(key: string): unknown | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function writeJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Quota überschritten oder Storage deaktiviert — schweigend ignorieren.
}
}
export function loadProgress(): Progress {
const raw = readJson<Partial<Progress>>(PROGRESS_KEY);
const base = emptyProgress();
if (!raw || typeof raw !== 'object') return base;
// Tolerant gegen fehlende oder korrupte Felder.
const incoming = raw as Partial<Progress>;
if (!incoming.perTarget) return base;
for (const t of TARGETS) {
const tp = incoming.perTarget[t];
if (!tp) continue;
base.perTarget[t] = {
runs: typeof tp.runs === 'number' ? tp.runs : 0,
top5: Array.isArray(tp.top5)
? tp.top5
.filter(
(r) => r && typeof r.stage === 'number' && typeof r.date === 'string'
)
.slice(0, 5) as RunRecord[]
: [],
};
}
return base;
}
export function saveProgress(p: Progress): void {
writeJson(PROGRESS_KEY, p);
}
export function recordRun(p: Progress, target: Target, stage: Stage): Progress {
const prev = p.perTarget[target] ?? { runs: 0, top5: [] };
const next: RunRecord = { stage, date: new Date().toISOString() };
const merged = [...prev.top5, next].sort((a, b) => {
if (b.stage !== a.stage) return b.stage - a.stage;
return b.date.localeCompare(a.date);
});
const top5 = merged.slice(0, 5);
return {
...p,
perTarget: {
...p.perTarget,
[target]: { runs: prev.runs + 1, top5 },
},
};
}
export function loadSettings(): Settings {
const raw = readJson<Partial<Settings>>(SETTINGS_KEY);
if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS };
const incoming = raw as Partial<Settings>;
return {
schemaVersion: SCHEMA_VERSION,
roundSeconds:
typeof incoming.roundSeconds === 'number' && incoming.roundSeconds > 0
? incoming.roundSeconds
: DEFAULT_SETTINGS.roundSeconds,
soundOn: typeof incoming.soundOn === 'boolean' ? incoming.soundOn : DEFAULT_SETTINGS.soundOn,
};
}
export function saveSettings(s: Settings): void {
writeJson(SETTINGS_KEY, s);
}

View File

@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { stageFor, isWin, isStageBoundary, STAGE_THRESHOLDS, TOTAL_TO_WIN } from './stages';
describe('stageFor', () => {
it('returns 0 (Boden) for 0 and 1 correct answers', () => {
expect(stageFor(0)).toBe(0);
expect(stageFor(1)).toBe(0);
});
it('returns 1 (Luftballon) at threshold 2 and below 4', () => {
expect(stageFor(2)).toBe(1);
expect(stageFor(3)).toBe(1);
});
it('returns 2 (Wolken) for 4..6', () => {
expect(stageFor(4)).toBe(2);
expect(stageFor(5)).toBe(2);
expect(stageFor(6)).toBe(2);
});
it('returns 3 (Mond) for 7..9', () => {
expect(stageFor(7)).toBe(3);
expect(stageFor(8)).toBe(3);
expect(stageFor(9)).toBe(3);
});
it('returns 4 (Sterne) at TOTAL_TO_WIN', () => {
expect(stageFor(10)).toBe(4);
expect(stageFor(11)).toBe(4);
});
});
describe('isWin', () => {
it('only returns true once total threshold reached', () => {
expect(isWin(9)).toBe(false);
expect(isWin(TOTAL_TO_WIN)).toBe(true);
expect(isWin(20)).toBe(true);
});
});
describe('isStageBoundary', () => {
it('returns true exactly at the configured thresholds', () => {
for (const t of STAGE_THRESHOLDS) expect(isStageBoundary(t)).toBe(true);
});
it('returns false between thresholds', () => {
expect(isStageBoundary(0)).toBe(false);
expect(isStageBoundary(1)).toBe(false);
expect(isStageBoundary(3)).toBe(false);
expect(isStageBoundary(5)).toBe(false);
});
});

36
src/lib/game/stages.ts Normal file
View File

@ -0,0 +1,36 @@
// Schwellen aus BRAINSTORM.md: 2/4/7/10 richtige Aufgaben pro Stufenaufstieg.
// Während der Runde zeigt die Rakete Stufen 0..4:
// 0 Boden, 1 Luftballon, 2 Wolken, 3 Mond, 4 Sterne.
// Stufe 5 (Regenbogenland) ist die Win-Celebration — wird nicht durch stageFor zurückgegeben,
// sondern durch isWin() signalisiert und im ResultScreen dargestellt.
export const STAGE_THRESHOLDS = [2, 4, 7, 10] as const;
export const TOTAL_TO_WIN = 10;
export type Stage = 0 | 1 | 2 | 3 | 4 | 5;
export const STAGE_NAMES: Record<Stage, string> = {
0: 'boden',
1: 'luftballon',
2: 'wolken',
3: 'mond',
4: 'sterne',
5: 'regenbogenland',
};
export function stageFor(correct: number): Stage {
if (correct >= STAGE_THRESHOLDS[3]) return 4; // 10+ → Sterne (Win-Übergang)
if (correct >= STAGE_THRESHOLDS[2]) return 3; // 7..9 → Mond
if (correct >= STAGE_THRESHOLDS[1]) return 2; // 4..6 → Wolken
if (correct >= STAGE_THRESHOLDS[0]) return 1; // 2..3 → Luftballon
return 0; // 0..1 → Boden
}
export function isWin(correct: number): boolean {
return correct >= TOTAL_TO_WIN;
}
// Bei welchen correctCount-Werten findet ein sichtbarer Stufenwechsel statt?
export function isStageBoundary(correct: number): boolean {
return (STAGE_THRESHOLDS as readonly number[]).includes(correct);
}

View File

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { generateTask, TARGETS, type Target } from './tasks';
describe('generateTask', () => {
it.each(TARGETS)('produces a valid decomposition for target %i', (target) => {
for (let i = 0; i < 50; i++) {
const task = generateTask(target);
expect(task.target).toBe(target);
expect(task.given).toBeGreaterThanOrEqual(1);
expect(task.given).toBeLessThanOrEqual(target - 1);
expect(task.given + task.answer).toBe(target);
}
});
it('always includes the correct answer in choices', () => {
for (const target of TARGETS) {
for (let i = 0; i < 30; i++) {
const task = generateTask(target);
expect(task.choices).toContain(task.answer);
}
}
});
it('produces choices without duplicates', () => {
for (const target of TARGETS) {
for (let i = 0; i < 30; i++) {
const task = generateTask(target);
const unique = new Set(task.choices);
expect(unique.size).toBe(task.choices.length);
}
}
});
it('returns 3 choices for targets >= 5', () => {
for (const target of [5, 6, 7, 8, 9, 10] as Target[]) {
const task = generateTask(target);
expect(task.choices.length).toBe(3);
}
});
it('returns 3 choices for target 4 (3 possible answers total)', () => {
const task = generateTask(4);
expect(task.choices.length).toBe(3);
});
it('avoids reusing the same given when possible', () => {
const target: Target = 7;
const prev = generateTask(target);
let differed = 0;
for (let i = 0; i < 50; i++) {
const t = generateTask(target, prev);
if (t.given !== prev.given) differed++;
}
expect(differed).toBe(50);
});
});

59
src/lib/game/tasks.ts Normal file
View File

@ -0,0 +1,59 @@
// Zahlzerlegungs-Aufgabengenerator.
//
// Eine Aufgabe für Zielzahl T besteht aus:
// given: vorgegebene Zerlegungszahl in [1..T-1]
// answer: T - given (das Kind muss diese tippen)
// choices: 3 große Buttons (richtige Antwort + 2 Distraktoren), gemischt
//
// Für sehr kleine Zielzahlen (T=4) gibt es nur 3 mögliche Antworten gesamt,
// daher ggf. nur 2 oder 3 Choices.
export type Target = 4 | 5 | 6 | 7 | 8 | 9 | 10;
export type Task = {
target: Target;
given: number;
answer: number;
choices: number[];
};
export const TARGETS: Target[] = [4, 5, 6, 7, 8, 9, 10];
const DESIRED_CHOICES = 3;
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(arr: T[]): T[] {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function generateTask(target: Target, prev?: Task): Task {
// Mögliche given-Werte: 1..target-1.
const candidates: number[] = [];
for (let i = 1; i < target; i++) candidates.push(i);
// Vermeide identisches given wie zuletzt, wenn Auswahl groß genug.
const givenPool =
prev && prev.target === target && candidates.length > 1
? candidates.filter((g) => g !== prev.given)
: candidates;
const given = givenPool[randInt(0, givenPool.length - 1)];
const answer = target - given;
// Distraktoren: alle möglichen Antworten außer der korrekten.
// Mögliche Antworten = 1..target-1 (gleicher Wertebereich).
const distractorPool = candidates.filter((n) => n !== answer);
const numDistractors = Math.min(DESIRED_CHOICES - 1, distractorPool.length);
const distractors = shuffle(distractorPool).slice(0, numDistractors);
const choices = shuffle([answer, ...distractors]);
return { target, given, answer, choices };
}

View File

@ -0,0 +1,141 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Target } from '../game/tasks';
import { game, stage, timeLeftSeconds, answer, abortGame } from '../stores/game';
import { goHome, goResult } from '../stores/route';
import { play } from '../audio/soundManager';
import HeightTrack from '../components/game/HeightTrack.svelte';
import TaskPrompt from '../components/game/TaskPrompt.svelte';
import AnswerButtons from '../components/game/AnswerButtons.svelte';
import Timer from '../components/game/Timer.svelte';
import BurstFx from '../components/game/BurstFx.svelte';
import IconButton from '../components/shared/IconButton.svelte';
type Props = { target: Target };
let { target }: Props = $props();
let lastBumpKey = $state(0);
let lastCountdownTickAt = $state(0);
function handleAnswer(value: number) {
play('tap');
if ($game.task && value === $game.task.answer) {
play('correct');
play('boost');
}
answer(value);
}
function handleAbort() {
abortGame();
goHome();
}
// Stufenaufstieg → level-Sound zusätzlich abspielen
$effect(() => {
if ($game.stageBumpKey > lastBumpKey) {
lastBumpKey = $game.stageBumpKey;
if ($game.status === 'won') play('fanfare');
else play('level');
}
});
// Countdown letzte 5 Sekunden
$effect(() => {
if ($game.status !== 'running') return;
const sec = $timeLeftSeconds;
if (sec > 0 && sec <= 5 && sec !== lastCountdownTickAt) {
lastCountdownTickAt = sec;
play('countdown');
}
});
// Bei Spielende → Result-Screen
$effect(() => {
if ($game.status === 'won' || $game.status === 'timeout') {
const t = setTimeout(() => goResult(target), $game.status === 'won' ? 1600 : 600);
return () => clearTimeout(t);
}
});
</script>
<div class="screen" in:fade={{ duration: 220 }}>
<header class="topbar">
<IconButton label="Zurück" onClick={handleAbort}>
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
<path d="M18 5 L8 14 L18 23" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</IconButton>
<Timer secondsLeft={$timeLeftSeconds} totalSeconds={Math.max(1, Math.round($game.totalMs / 1000))} />
</header>
<div class="layout">
<aside class="track-wrap">
<HeightTrack stage={$stage} won={$game.status === 'won'} />
<BurstFx triggerKey={$game.stageBumpKey} />
</aside>
<main class="play">
{#if $game.task && $game.status === 'running'}
<TaskPrompt task={$game.task} />
<AnswerButtons
choices={$game.task.choices}
correctAnswer={$game.task.answer}
onAnswer={handleAnswer}
/>
{:else if $game.status === 'won'}
<div class="end-message">
<span class="big">🌈</span>
</div>
{/if}
</main>
</div>
</div>
<style>
.screen {
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
padding: 12px;
gap: 8px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.layout {
display: grid;
grid-template-columns: 30% 1fr;
gap: 14px;
min-height: 0;
}
.track-wrap {
position: relative;
min-height: 0;
}
.play {
display: grid;
grid-template-rows: 1fr auto;
gap: 24px;
align-items: center;
padding-bottom: 12px;
}
.end-message {
display: grid;
place-items: center;
height: 100%;
}
.big {
font-size: clamp(80px, 18vw, 200px);
animation: pop 0.6s ease;
}
@keyframes pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (max-width: 640px) {
.layout { grid-template-columns: 24% 1fr; }
}
</style>

View File

@ -0,0 +1,95 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { TARGETS, type Target } from '../game/tasks';
import { goGame, goSettings } from '../stores/route';
import { startGame } from '../stores/game';
import { play, unlockAudio } from '../audio/soundManager';
import NumberCard from '../components/home/NumberCard.svelte';
import SoundToggle from '../components/shared/SoundToggle.svelte';
import IconButton from '../components/shared/IconButton.svelte';
function handlePick(t: Target) {
unlockAudio();
play('tap');
startGame(t);
goGame(t);
}
</script>
<div class="screen" in:fade={{ duration: 220 }}>
<header class="topbar">
<SoundToggle />
<h1 class="visually-hidden">Zahlzerlegung</h1>
<IconButton label="Einstellungen" onClick={goSettings}>
<svg width="34" height="34" viewBox="0 0 32 32" aria-hidden="true">
<path
d="M14 3 L18 3 L18.6 6.4 L21.5 7.6 L23.9 5.3 L26.7 8.1 L24.4 10.5 L25.6 13.4 L29 14 L29 18 L25.6 18.6 L24.4 21.5 L26.7 23.9 L23.9 26.7 L21.5 24.4 L18.6 25.6 L18 29 L14 29 L13.4 25.6 L10.5 24.4 L8.1 26.7 L5.3 23.9 L7.6 21.5 L6.4 18.6 L3 18 L3 14 L6.4 13.4 L7.6 10.5 L5.3 8.1 L8.1 5.3 L10.5 7.6 L13.4 6.4 Z M16 12 A4 4 0 1 0 16.001 12 Z"
fill="currentColor"
fill-rule="evenodd"
/>
</svg>
</IconButton>
</header>
<div class="cards-grid">
{#each TARGETS as target (target)}
<NumberCard {target} onClick={handlePick} />
{/each}
</div>
</div>
<style>
.screen {
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
padding: 16px;
gap: 12px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.cards-grid {
/* Karten haben aspect-ratio 3/4. Bei 7 Karten ergeben sich je nach Spaltenzahl
2/3/4 Reihen — Grid-Breite wird so begrenzt, dass die Reihen in die verfügbare
Höhe passen. Formel: max-width = C·3·(H - (R-1)·g) / (4R) + (C-1)·g. */
--gap: 14px;
--avail-h: calc(100dvh - 130px);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--gap);
align-content: center;
justify-content: center;
width: 100%;
/* 4 Spalten, 2 Reihen */
max-width: calc(var(--avail-h) * 1.5 + var(--gap) * 1.5);
margin-inline: auto;
padding-bottom: 10px;
}
@media (max-width: 640px) {
.cards-grid {
grid-template-columns: repeat(3, 1fr);
/* 3 Spalten, 3 Reihen */
max-width: calc(var(--avail-h) * 0.75 + var(--gap) * 0.5);
}
}
@media (max-width: 380px) {
.cards-grid {
grid-template-columns: repeat(2, 1fr);
/* 2 Spalten, 4 Reihen */
max-width: calc(var(--avail-h) * 0.375 - var(--gap) * 0.125);
}
}
</style>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import type { Target } from '../game/tasks';
import { progress } from '../stores/progress';
import { startGame } from '../stores/game';
import { goHome, goGame } from '../stores/route';
import { play, unlockAudio } from '../audio/soundManager';
import Rocket from '../components/svg/Rocket.svelte';
import Rainbow from '../components/svg/Rainbow.svelte';
import HighscoreColumn from '../components/home/HighscoreColumn.svelte';
type Props = { target: Target };
let { target }: Props = $props();
// Aktueller Run = top-Eintrag mit dem letzten Datum
const lastRunDate = $derived($progress.perTarget[target]?.top5[0]?.date ?? null);
const lastStage = $derived(($progress.perTarget[target]?.top5[0]?.stage ?? 0) as number);
const isWin = $derived(lastStage >= 4);
function retry() {
unlockAudio();
play('tap');
startGame(target);
goGame(target);
}
function home() {
play('tap');
goHome();
}
</script>
<div class="screen" in:fade={{ duration: 240 }}>
<header>
<h2 class="target-label">
<span class="num">{target}</span>
</h2>
</header>
<div class="celebration">
{#if isWin}
<div class="rainbow-wrap" in:fly={{ y: 30, duration: 600 }}>
<Rainbow size={260} />
</div>
{/if}
<div class="rocket-wrap" in:fly={{ y: 60, duration: 700 }}>
<Rocket size={160} flameAnimated={false} />
</div>
</div>
<aside class="scores">
<HighscoreColumn {target} highlightDate={lastRunDate} />
</aside>
<footer class="actions">
<button class="action retry" type="button" onclick={retry} aria-label="Nochmal">
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
<path d="M10 22 A12 12 0 1 1 22 34" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
<path d="M22 34 L16 30 L18 38 Z" fill="currentColor" />
</svg>
</button>
<button class="action home" type="button" onclick={home} aria-label="Zurück zur Übersicht">
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
<path d="M6 22 L22 8 L38 22 L34 22 L34 36 L26 36 L26 26 L18 26 L18 36 L10 36 L10 22 Z" fill="currentColor" />
</svg>
</button>
</footer>
</div>
<style>
.screen {
height: 100%;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'header header'
'celebration scores'
'actions actions';
padding: 16px;
gap: 12px;
}
header { grid-area: header; display: flex; justify-content: center; }
.target-label .num {
font-size: clamp(48px, 8vw, 80px);
font-weight: 900;
color: var(--c-text-on-dark);
background: rgba(255,255,255,0.18);
border-radius: 18px;
padding: 6px 28px;
}
.celebration {
grid-area: celebration;
display: grid;
place-items: center;
position: relative;
}
.rocket-wrap { z-index: 2; }
.rainbow-wrap {
position: absolute;
bottom: 12%;
z-index: 1;
}
.scores {
grid-area: scores;
width: 110px;
height: 100%;
}
.actions {
grid-area: actions;
display: flex;
justify-content: center;
gap: 24px;
}
.action {
width: 96px;
height: 96px;
border-radius: 50%;
color: white;
display: grid;
place-items: center;
box-shadow: 0 6px 0 rgba(0,0,0,0.18);
transition: transform 0.1s ease;
}
.action:active { transform: translateY(4px); box-shadow: 0 2px 0 rgba(0,0,0,0.18); }
.action.retry { background: var(--c-correct); }
.action.home { background: var(--c-rocket-body); }
</style>

View File

@ -0,0 +1,120 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { settings, ROUND_SECONDS_OPTIONS, setRoundSeconds, toggleSound } from '../stores/settings';
import { goHome } from '../stores/route';
import { play } from '../audio/soundManager';
import IconButton from '../components/shared/IconButton.svelte';
</script>
<div class="screen" in:fade={{ duration: 220 }}>
<header>
<IconButton label="Zurück" onClick={goHome}>
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
<path d="M18 5 L8 14 L18 23" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</IconButton>
</header>
<section class="setting">
<div class="label">
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
<circle cx="22" cy="24" r="14" fill="none" stroke="currentColor" stroke-width="3" />
<path d="M22 14 L22 24 L29 27" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M16 6 L16 10 M28 6 L28 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<div class="options">
{#each ROUND_SECONDS_OPTIONS as opt (opt)}
<button
type="button"
class="opt"
class:selected={$settings.roundSeconds === opt}
onclick={() => { play('tap'); setRoundSeconds(opt); }}
aria-label={`${opt} Sekunden`}
>
<span class="opt-num">{opt}</span>
</button>
{/each}
</div>
</section>
<section class="setting">
<div class="label">
<svg width="44" height="44" viewBox="0 0 44 44" aria-hidden="true">
<path d="M8 16 L16 16 L26 8 L26 36 L16 28 L8 28 Z" fill="currentColor" />
</svg>
</div>
<div class="options">
<button
type="button"
class="opt big-toggle"
class:selected={$settings.soundOn}
onclick={() => { play('tap'); toggleSound(); }}
aria-label={$settings.soundOn ? 'Ton aus' : 'Ton an'}
>
{#if $settings.soundOn}
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<path d="M14 14 L20 14 L28 8 L28 32 L20 26 L14 26 Z" fill="currentColor" />
<path d="M30 12 Q36 20 30 28" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" />
</svg>
{:else}
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<path d="M14 14 L20 14 L28 8 L28 32 L20 26 L14 26 Z" fill="currentColor" />
<path d="M30 14 L38 26 M38 14 L30 26" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
{/if}
</button>
</div>
</section>
</div>
<style>
.screen {
height: 100%;
display: grid;
grid-template-rows: auto 1fr 1fr;
padding: 16px;
gap: 18px;
color: var(--c-text-on-dark);
}
.setting {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 16px;
background: rgba(255,255,255,0.12);
border-radius: var(--radius-card);
padding: 16px;
}
.label {
display: grid;
place-items: center;
}
.options {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.opt {
min-width: 84px;
min-height: 84px;
background: rgba(255, 255, 255, 0.18);
color: var(--c-text-on-dark);
border-radius: 18px;
font-weight: 800;
font-size: 36px;
display: grid;
place-items: center;
transition: transform 0.08s ease, background 0.15s ease;
}
.opt:active { transform: scale(0.95); }
.opt.selected {
background: var(--c-rocket-window);
color: var(--c-text);
box-shadow: 0 0 0 4px rgba(255,229,102,0.4);
}
.big-toggle { padding: 10px 24px; }
.opt-num { display: inline-block; }
</style>

140
src/lib/stores/game.ts Normal file
View File

@ -0,0 +1,140 @@
import { get, writable, derived } from 'svelte/store';
import { generateTask, type Target, type Task } from '../game/tasks';
import { isWin, stageFor, type Stage } from '../game/stages';
import { settings } from './settings';
import { recordResult } from './progress';
export type GameStatus = 'idle' | 'running' | 'won' | 'timeout';
export type GameState = {
status: GameStatus;
target: Target | null;
task: Task | null;
prevTask: Task | null;
correctCount: number;
timeLeftMs: number;
totalMs: number;
lastWasCorrect: boolean | null;
stageBumpKey: number; // monoton wachsend → triggert FX-Animation
};
const initial: GameState = {
status: 'idle',
target: null,
task: null,
prevTask: null,
correctCount: 0,
timeLeftMs: 0,
totalMs: 0,
lastWasCorrect: null,
stageBumpKey: 0,
};
export const game = writable<GameState>(initial);
export const stage = derived(game, ($g): Stage => stageFor($g.correctCount));
export const timeLeftSeconds = derived(game, ($g) => Math.max(0, Math.ceil($g.timeLeftMs / 1000)));
let tickHandle: number | null = null;
let lastTickAt = 0;
function clearTick() {
if (tickHandle !== null) {
cancelAnimationFrame(tickHandle);
tickHandle = null;
}
}
function tick() {
const now = performance.now();
const delta = now - lastTickAt;
lastTickAt = now;
let ended = false;
game.update((g) => {
if (g.status !== 'running') return g;
const next = Math.max(0, g.timeLeftMs - delta);
if (next <= 0) {
ended = true;
return { ...g, timeLeftMs: 0, status: 'timeout' };
}
return { ...g, timeLeftMs: next };
});
if (ended) {
clearTick();
finalize();
return;
}
tickHandle = requestAnimationFrame(tick);
}
function finalize() {
const g = get(game);
if (g.target === null) return;
recordResult(g.target, stageFor(g.correctCount));
}
export function startGame(target: Target): void {
clearTick();
const totalMs = get(settings).roundSeconds * 1000;
const firstTask = generateTask(target);
game.set({
status: 'running',
target,
task: firstTask,
prevTask: null,
correctCount: 0,
timeLeftMs: totalMs,
totalMs,
lastWasCorrect: null,
stageBumpKey: 0,
});
lastTickAt = performance.now();
tickHandle = requestAnimationFrame(tick);
}
export function answer(value: number): void {
const g = get(game);
if (g.status !== 'running' || !g.task || g.target === null) return;
const correct = value === g.task.answer;
if (correct) {
const nextCount = g.correctCount + 1;
const prevStage = stageFor(g.correctCount);
const newStage = stageFor(nextCount);
const stageBump = newStage > prevStage;
if (isWin(nextCount)) {
clearTick();
game.update((s) => ({
...s,
status: 'won',
correctCount: nextCount,
lastWasCorrect: true,
stageBumpKey: s.stageBumpKey + 1,
}));
finalize();
return;
}
const nextTask = generateTask(g.target, g.task);
game.update((s) => ({
...s,
correctCount: nextCount,
prevTask: s.task,
task: nextTask,
lastWasCorrect: true,
stageBumpKey: stageBump ? s.stageBumpKey + 1 : s.stageBumpKey,
}));
} else {
game.update((s) => ({ ...s, lastWasCorrect: false }));
}
}
export function nextTaskAfterWrong(): void {
const g = get(game);
if (g.status !== 'running' || !g.task || g.target === null) return;
const nextTask = generateTask(g.target, g.task);
game.update((s) => ({ ...s, prevTask: s.task, task: nextTask, lastWasCorrect: null }));
}
export function abortGame(): void {
clearTick();
game.set(initial);
}

View File

@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
import {
emptyProgress,
loadProgress,
recordRun,
saveProgress,
type Progress,
} from '../game/persistence';
import type { Target } from '../game/tasks';
import type { Stage } from '../game/stages';
const initial: Progress = typeof window === 'undefined' ? emptyProgress() : loadProgress();
export const progress = writable<Progress>(initial);
progress.subscribe((p) => {
if (typeof window !== 'undefined') saveProgress(p);
});
export function recordResult(target: Target, stage: Stage): void {
progress.update((p) => recordRun(p, target, stage));
}

15
src/lib/stores/route.ts Normal file
View File

@ -0,0 +1,15 @@
import { writable } from 'svelte/store';
import type { Target } from '../game/tasks';
export type Route =
| { name: 'home' }
| { name: 'game'; target: Target }
| { name: 'result'; target: Target }
| { name: 'settings' };
export const route = writable<Route>({ name: 'home' });
export const goHome = () => route.set({ name: 'home' });
export const goGame = (target: Target) => route.set({ name: 'game', target });
export const goResult = (target: Target) => route.set({ name: 'result', target });
export const goSettings = () => route.set({ name: 'settings' });

View File

@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
import { loadSettings, saveSettings, type Settings } from '../game/persistence';
export const ROUND_SECONDS_OPTIONS = [30, 60, 90, 120] as const;
const initial: Settings =
typeof window === 'undefined'
? { schemaVersion: 1, roundSeconds: 60, soundOn: true }
: loadSettings();
export const settings = writable<Settings>(initial);
settings.subscribe((s) => {
if (typeof window !== 'undefined') saveSettings(s);
});
export function setRoundSeconds(value: number): void {
settings.update((s) => ({ ...s, roundSeconds: value }));
}
export function toggleSound(): void {
settings.update((s) => ({ ...s, soundOn: !s.soundOn }));
}

7
src/main.ts Normal file
View File

@ -0,0 +1,7 @@
import { mount } from 'svelte';
import App from './App.svelte';
import './app.css';
const app = mount(App, { target: document.getElementById('app')! });
export default app;

33
src/test-setup.ts Normal file
View File

@ -0,0 +1,33 @@
import { beforeEach } from 'vitest';
class MemoryStorage implements Storage {
private map = new Map<string, string>();
get length(): number {
return this.map.size;
}
clear(): void {
this.map.clear();
}
getItem(key: string): string | null {
return this.map.has(key) ? this.map.get(key)! : null;
}
setItem(key: string, value: string): void {
this.map.set(key, String(value));
}
removeItem(key: string): void {
this.map.delete(key);
}
key(index: number): string | null {
return Array.from(this.map.keys())[index] ?? null;
}
}
// Node 25 ships an experimental localStorage that lacks .clear; replace it
// before every test so jsdom-style behaviour is reliable.
beforeEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
value: new MemoryStorage(),
configurable: true,
writable: true,
});
});

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

5
svelte.config.js Normal file
View File

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["svelte", "vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte"]
}

32
vite.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
svelte(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/favicon.svg'],
manifest: {
name: 'Zahlzerlegung',
short_name: 'Zahlen',
description: 'Zahlzerlegung von 4 bis 10 spielerisch üben',
theme_color: '#1a2c5c',
background_color: '#1a2c5c',
display: 'standalone',
orientation: 'any',
lang: 'de',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
],
test: {
environment: 'jsdom',
globals: true,
},
});

11
vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: false })],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
});