Init
This commit is contained in:
commit
68e097dbf5
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist/
|
||||||
|
node_modules
|
||||||
|
.claude/settings.local.json
|
||||||
198
BRAINSTORM.md
Normal file
198
BRAINSTORM.md
Normal 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
77
CLAUDE.md
Normal 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 4–10 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. 84–96 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−(R−1)·g) / (4·R) + (C−1)·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
14
index.html
Normal 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
7398
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
7
public/icons/favicon.svg
Normal 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
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
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
27
src/App.svelte
Normal file
27
src/App.svelte
Normal 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
61
src/app.css
Normal 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;
|
||||||
|
}
|
||||||
103
src/lib/audio/soundManager.ts
Normal file
103
src/lib/audio/soundManager.ts
Normal 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();
|
||||||
|
}
|
||||||
91
src/lib/components/game/AnswerButtons.svelte
Normal file
91
src/lib/components/game/AnswerButtons.svelte
Normal 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>
|
||||||
67
src/lib/components/game/BurstFx.svelte
Normal file
67
src/lib/components/game/BurstFx.svelte
Normal 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>
|
||||||
93
src/lib/components/game/HeightTrack.svelte
Normal file
93
src/lib/components/game/HeightTrack.svelte
Normal 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>
|
||||||
64
src/lib/components/game/TaskPrompt.svelte
Normal file
64
src/lib/components/game/TaskPrompt.svelte
Normal 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>
|
||||||
49
src/lib/components/game/Timer.svelte
Normal file
49
src/lib/components/game/Timer.svelte
Normal 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>
|
||||||
79
src/lib/components/home/HighscoreColumn.svelte
Normal file
79
src/lib/components/home/HighscoreColumn.svelte
Normal 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>
|
||||||
107
src/lib/components/home/NumberCard.svelte
Normal file
107
src/lib/components/home/NumberCard.svelte
Normal 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>
|
||||||
30
src/lib/components/shared/IconButton.svelte
Normal file
30
src/lib/components/shared/IconButton.svelte
Normal 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>
|
||||||
37
src/lib/components/shared/SoundToggle.svelte
Normal file
37
src/lib/components/shared/SoundToggle.svelte
Normal 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>
|
||||||
10
src/lib/components/svg/Balloon.svelte
Normal file
10
src/lib/components/svg/Balloon.svelte
Normal 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>
|
||||||
12
src/lib/components/svg/Cloud.svelte
Normal file
12
src/lib/components/svg/Cloud.svelte
Normal 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>
|
||||||
17
src/lib/components/svg/Crown.svelte
Normal file
17
src/lib/components/svg/Crown.svelte
Normal 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>
|
||||||
11
src/lib/components/svg/Moon.svelte
Normal file
11
src/lib/components/svg/Moon.svelte
Normal 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>
|
||||||
14
src/lib/components/svg/Rainbow.svelte
Normal file
14
src/lib/components/svg/Rainbow.svelte
Normal 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>
|
||||||
50
src/lib/components/svg/Rocket.svelte
Normal file
50
src/lib/components/svg/Rocket.svelte
Normal 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>
|
||||||
13
src/lib/components/svg/Star.svelte
Normal file
13
src/lib/components/svg/Star.svelte
Normal 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>
|
||||||
86
src/lib/game/persistence.test.ts
Normal file
86
src/lib/game/persistence.test.ts
Normal 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
119
src/lib/game/persistence.ts
Normal 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);
|
||||||
|
}
|
||||||
51
src/lib/game/stages.test.ts
Normal file
51
src/lib/game/stages.test.ts
Normal 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
36
src/lib/game/stages.ts
Normal 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);
|
||||||
|
}
|
||||||
56
src/lib/game/tasks.test.ts
Normal file
56
src/lib/game/tasks.test.ts
Normal 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
59
src/lib/game/tasks.ts
Normal 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 };
|
||||||
|
}
|
||||||
141
src/lib/screens/GameScreen.svelte
Normal file
141
src/lib/screens/GameScreen.svelte
Normal 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>
|
||||||
95
src/lib/screens/HomeScreen.svelte
Normal file
95
src/lib/screens/HomeScreen.svelte
Normal 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>
|
||||||
128
src/lib/screens/ResultScreen.svelte
Normal file
128
src/lib/screens/ResultScreen.svelte
Normal 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>
|
||||||
120
src/lib/screens/SettingsScreen.svelte
Normal file
120
src/lib/screens/SettingsScreen.svelte
Normal 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
140
src/lib/stores/game.ts
Normal 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);
|
||||||
|
}
|
||||||
22
src/lib/stores/progress.ts
Normal file
22
src/lib/stores/progress.ts
Normal 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
15
src/lib/stores/route.ts
Normal 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' });
|
||||||
23
src/lib/stores/settings.ts
Normal file
23
src/lib/stores/settings.ts
Normal 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
7
src/main.ts
Normal 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
33
src/test-setup.ts
Normal 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
2
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
5
svelte.config.js
Normal file
5
svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
32
vite.config.ts
Normal 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
11
vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user