Compare commits
No commits in common. "e7d4b720bc3845fc4d1ec5410426a24ab9483a6a" and "2db4a7cb79b4009cbfb30baa17251d782789b188" have entirely different histories.
e7d4b720bc
...
2db4a7cb79
19 changed files with 4827 additions and 181 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
node_modules/
|
||||||
322
README.md
322
README.md
|
|
@ -1,181 +1,207 @@
|
||||||
# Spiel-Generator
|
# 🎲 Spiel-Generator
|
||||||
### PH Weingarten · Medien- und Bildungsmanagement & Informatik (Lehramt)
|
### PH Weingarten · Medien- und Bildungsmanagement & Informatik (Lehramt)
|
||||||
|
|
||||||
---
|
> Browserbasierter Brettspiel-Generator für den Workshop — Schülerinnen und Schüler der ca. 8. Klasse erstellen ihr eigenes digitales Brettspiel mit Mini-Games, ganz ohne Programmierkenntnisse. Dabei entsteht live valider **Python-Code**, der das Spielkonzept abbildet und das Gefühl echten Programmierens vermittelt.
|
||||||
|
|
||||||
## Was ist das?
|
|
||||||
|
|
||||||
Ein browserbasierter **Brettspiel-Generator** für einen Workshop an der PH Weingarten, der sich an Schülerinnen und Schüler der ca. 8. Klasse richtet. Kinder erstellen ihr eigenes digitales Brettspiel mit Mini-Games – ganz ohne Programmierkenntnisse. Im Hintergrund wird dabei live gültiger **Python-Code** generiert, der das Spielkonzept als Quellcode abbildet und den Schülerinnen und Schülern das Gefühl gibt, wirklich zu programmieren.
|
|
||||||
|
|
||||||
Das Projekt besteht aus zwei Dateien:
|
|
||||||
|
|
||||||
| Datei | Funktion |
|
|
||||||
|-------|----------|
|
|
||||||
| `editor.html` | 5-Schritte-Editor-Wizard zur Spielerstellung |
|
|
||||||
| `game.html` | Das fertige spielbare Brettspiel |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Projektstruktur & Architektur
|
## Projektstruktur
|
||||||
|
|
||||||
- **Technologie:** Reines HTML + CSS + JavaScript (keine Abhängigkeiten, kein Build-Prozess)
|
```
|
||||||
- **Hosting:** Gedacht für GitHub Pages
|
edu-boardgame-generator/
|
||||||
- **Datenaustausch:** Spielkonfiguration wird via `localStorage.gameConfig` gespeichert; `game.html` liest daraus beim Start
|
├── editor.html # 5-Schritte-Editor-Wizard
|
||||||
- **Keine Serverpflicht:** Alles läuft clientseitig
|
├── game.html # Das fertige spielbare Brettspiel
|
||||||
|
├── codegen.js # Python Live-Code-Generator (Split-Screen)
|
||||||
|
├── logo.png # PH Weingarten Logo
|
||||||
|
└── minigames/
|
||||||
|
├── _api.js # Gemeinsame API für alle Mini-Games
|
||||||
|
├── snake.js # 🐍 Snake
|
||||||
|
├── flappy.js # 🐦 Flappy Bird
|
||||||
|
├── memory.js # 🃏 Memory
|
||||||
|
├── quiz.js # ❓ Quiz
|
||||||
|
├── reaction.js # ⚡ Reaktionstest
|
||||||
|
├── basketball.js # 🏀 Basketball
|
||||||
|
├── catch.js # 🍎 Äpfel fangen
|
||||||
|
├── maze.js # 🌀 Labyrinth (generiertes Maze)
|
||||||
|
├── simon.js # 🔴 Simon Says
|
||||||
|
├── typing.js # ⌨️ Tipp-Rennen
|
||||||
|
├── puzzle.js # 🧩 15-Zahlen-Puzzle
|
||||||
|
└── spotdiff.js # 🔍 Fehler finden
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie & Architektur
|
||||||
|
|
||||||
|
- **Reines HTML + CSS + JavaScript** — keine Abhängigkeiten, kein Build-Prozess
|
||||||
|
- **Hosting:** GitHub Pages (oder beliebiger statischer Webserver)
|
||||||
|
- **Datenaustausch:** Spielkonfiguration via `localStorage.gameConfig`
|
||||||
|
- **Mini-Game API:** Jedes Modul exportiert `launch()` (Spiel) + `preview()` (Editor-Test)
|
||||||
|
- **⚠️ Wichtig:** Wegen `<script src="...">` muss die App über HTTP(S) laufen — nicht direkt per `file://`
|
||||||
|
|
||||||
|
### Lokalen Server starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python (empfohlen)
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# → http://localhost:8000/editor.html
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
npx serve .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mini-Game API
|
||||||
|
|
||||||
|
Jede Mini-Game-Datei registriert sich als `window.MG_<id>`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.MG_snake = {
|
||||||
|
id: 'snake',
|
||||||
|
emoji: '🐍',
|
||||||
|
name: 'Snake',
|
||||||
|
desc: 'Steuere die Schlange...',
|
||||||
|
controls: 'Pfeiltasten / WASD',
|
||||||
|
multi: 1, // 1=einmalig, 3=max 3x, 99=unbegrenzt
|
||||||
|
|
||||||
|
// Vollständiges Spiel (game.html)
|
||||||
|
launch(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||||
|
|
||||||
|
// Kompakte Vorschau (Editor Test-Popup)
|
||||||
|
preview(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`cfg` enthält: `{ theme, quizData, fieldIndex, devName, gameName }`
|
||||||
|
|
||||||
|
Gemeinsame Hilfsfunktionen stehen über `window.MGAPI` bereit:
|
||||||
|
`makeCanvas`, `onResult`, `text`, `roundRect`, `resultScreen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neues Mini-Game hinzufügen
|
||||||
|
|
||||||
|
1. Datei `minigames/meinspiel.js` anlegen (Vorlage: eine bestehende Datei kopieren)
|
||||||
|
2. `window.MG_meinspiel = { ... }` implementieren
|
||||||
|
3. In `editor.html` und `game.html` eintragen:
|
||||||
|
- `<script src="minigames/meinspiel.js"></script>` vor `</body>`
|
||||||
|
4. In der Editor-Logik (`MINIGAMES`-Array in `editor.html`) einen Eintrag hinzufügen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
### Vollständig funktionierend
|
### ✅ Vollständig implementiert
|
||||||
|
|
||||||
#### Editor (`editor.html`)
|
**Editor (`editor.html`)**
|
||||||
|
- Schritt 1: Entwicklername, Spielname, Beschreibung, Vorschau
|
||||||
|
- Schritt 2: 12 Spielfiguren, 12 Welten/Hintergründe
|
||||||
|
- Schritt 3: Würfeln/Schritte, Leben/Punkte, Feldanzahl, Drag&Drop, Erzähltexte
|
||||||
|
- Schritt 4: Quiz-Fragen mit 4 Antworten
|
||||||
|
- Schritt 5: Review, Spielstart, Feedback-Auswertung
|
||||||
|
- Python Live-Code Split-Screen (progressiv, fokusgetriggert)
|
||||||
|
- ↺ Reset-Button, localStorage-Persistenz, responsiv
|
||||||
|
|
||||||
- **Schritt 1 – Grundinfos:** Entwicklername, Spielname, Beschreibung, Namensvorschläge, Live-Vorschau-Karte
|
**Spiel (`game.html`)**
|
||||||
- **Schritt 2 – Gestaltung:** 12 Spielfiguren, 12 Hintergründe/Welten, Live-Kombi-Vorschau
|
- Animiertes Canvas-Spielfeld, themenabhängige Farben/Partikel
|
||||||
- **Schritt 3 – Spielfeld & Regeln:**
|
- Würfelanimation, Story-Overlays, HUD
|
||||||
- Würfeln vs. Einzel-Schritt
|
- Win/GameOver-Screen mit Statistiken
|
||||||
- Leben-System vs. Punkte-System
|
|
||||||
- Feldanzahl (6–20) mit +/- Steuerung
|
|
||||||
- Drag-&-Drop Palette → Spielfeld (funktioniert ✅)
|
|
||||||
- Klick-to-Place für Mini-Games
|
|
||||||
- Erzähltexte vor/nach Feldern mit Emoji-Picker
|
|
||||||
- Board-Builder mit visueller Felddarstellung
|
|
||||||
- **Schritt 4 – Quiz:** Pro Quiz-Feld eine Karte mit Frage + 4 Antworten + richtiger Antwort; Autofill-Beispiele
|
|
||||||
- **Schritt 5 – Testen:** Review-Timeline, Launch-Button zu `game.html`, 8-Fragen-Feedback-Auswertung
|
|
||||||
|
|
||||||
#### Python-Live-Code (Split-Screen rechts)
|
### 🎮 Mini-Games
|
||||||
|
|
||||||
- Progressiver Aufbau: Code erscheint **Schritt für Schritt** wenn Felder angeklickt/befüllt werden
|
| Game | Status Editor-Vorschau | Status Vollspiel |
|
||||||
- Beginn: Leerer Code mit Hinweiskommentar
|
|------|----------------------|-----------------|
|
||||||
- Fokus-getriggert: Jedes Feld/Button enthüllt genau den zugehörigen Codeabschnitt
|
| 🐍 Snake | ✅ | ✅ |
|
||||||
- **Korrekte Python-Syntax** mit echtem Syntaxhighlighting (Keywords, Strings, Funktionen, Kommentare)
|
| 🐦 Flappy Bird | ✅ | ✅ |
|
||||||
- Dynamischer Dateiname: `spielname_von_entwicklername.py`
|
| 🃏 Memory | ✅ | ✅ |
|
||||||
- Resizer-Balken: Split-Breite per Maus verschiebbar
|
| ❓ Quiz | ✅ | ✅ |
|
||||||
- Zeilennummern, Statusanzeige (`● Live` / `● Bereit`)
|
| ⚡ Reaktionstest | ✅ | ✅ |
|
||||||
- Dateiname-Header mit macOS-Stoplight-Dots
|
| 🏀 Basketball | ✅ | ✅ |
|
||||||
|
| 🍎 Äpfel fangen | ✅ | ✅ |
|
||||||
|
| 🌀 Labyrinth | ✅ (generiertes Maze) | ✅ |
|
||||||
|
| 🔴 Simon Says | ✅ | ✅ |
|
||||||
|
| ⌨️ Tipp-Rennen | ✅ | ✅ |
|
||||||
|
| 🧩 Zahlen-Puzzle | ✅ (15-Puzzle) | ✅ |
|
||||||
|
| 🔍 Fehler finden | ✅ (Bauernhof-Szene) | ✅ |
|
||||||
|
|
||||||
#### Allgemein
|
### 🔧 Offene Aufgaben
|
||||||
|
|
||||||
- PH Weingarten Logo + Studiengänge im Header
|
| Aufgabe | Priorität |
|
||||||
- Sticky Progress-Bar (volle Breite) mit 5 Schritt-Dots
|
|---------|-----------|
|
||||||
- Zusammenfassungs-Banner beim Vorwärtsnavigieren
|
| **Veröffentlichungs-System**: ✅ Implementiert (URL-Hash, QR-Code, Share-Buttons) | ✅ Fertig |
|
||||||
- `localStorage`-Persistenz (Zustand bleibt nach Reload)
|
| **Mehr Szenen für Fehler finden** (spotdiff): 3–5 verschiedene Bilder | 🟡 Mittel |
|
||||||
- **↺ Reset-Button** zum sauberen Neustart (löscht localStorage)
|
| **Schwierigkeitsgrade** in Mini-Games (leicht/mittel/schwer) | 🟡 Mittel |
|
||||||
- Responsiv: Code-Panel blendet sich unter 900px aus
|
| **Touch/Mobile** Drag&Drop im Editor | 🟡 Mittel |
|
||||||
- Scrollbar-Styling einheitlich (lila, schlank)
|
| **Highscore-System** optional | 🟢 Niedrig |
|
||||||
|
| **Mehrsprachigkeit** | 🟢 Niedrig |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Spiel (`game.html`)
|
## URL-Sharing System
|
||||||
|
|
||||||
- Vollständiges animiertes Spielfeld mit Canvas-Rendering
|
Der "🚀 Veröffentlichen"-Button im Editor erzeugt einen **selbsttragenden Link**:
|
||||||
- Thema-abhängige Farben & Partikel (12 Hintergründe/Themen)
|
|
||||||
- Spielfigur mit Bounce-Effekt und Glow
|
```
|
||||||
- Farbkodierte Felder pro Mini-Game-Typ
|
https://deinserver.de/game.html#z:H4sIAAAAAAAAA6tWKkktLlGyUlIqS...
|
||||||
- Besuchter-Pfad leuchtet auf
|
```
|
||||||
- HUD: Feld-Position, Leben oder Punkte
|
|
||||||
- Würfel-Animation
|
**Technisch:**
|
||||||
- Story-Overlay (Erzähltexte)
|
- Config → `JSON.stringify` → `pako.deflate` (gzip) → `Base64` → `#URL-Hash`
|
||||||
- Mini-Game-Overlay mit Beschreibung und Steuerungshinweis
|
- Typische Länge: ~800–1100 Zeichen ✅ QR-Code-tauglich (< 4000 Zeichen)
|
||||||
- Win-Screen und Game-Over-Screen mit Statistiken
|
- `game.html` liest beim Start den Hash und dekomprimiert ihn
|
||||||
|
- Fallback auf `localStorage` (Editor-Testmodus), dann auf Demo-Config
|
||||||
|
|
||||||
|
**Share-Modal** bietet:
|
||||||
|
- 📋 Link kopieren
|
||||||
|
- 📱 QR-Code (direkt scannen mit Handy-Kamera)
|
||||||
|
- 📧 Per E-Mail teilen
|
||||||
|
- 💬 WhatsApp
|
||||||
|
- 📲 Native Share API (auf Mobil)
|
||||||
|
|
||||||
|
**Kein Backend nötig** – der Server liefert nur statische Dateien.
|
||||||
|
|
||||||
|
## Deployment (GitHub Pages)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.md-phw.de:mbm/edu-boardgame-generator.git
|
||||||
|
cd edu-boardgame-generator
|
||||||
|
|
||||||
|
# Dateien bearbeiten ...
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: ..."
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Pages → Settings → Pages → Branch: `main` / Root
|
||||||
|
|
||||||
|
**Wichtig:** Der Generator muss über `https://` aufgerufen werden (nicht `file://`), damit `<script src>` funktioniert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🎮 Mini-Games – Überblick
|
## Cache leeren (Entwicklung)
|
||||||
|
|
||||||
#### Im Spiel (`game.html`) – vollständig spielbar
|
```bash
|
||||||
|
# Browser-Konsole
|
||||||
|
localStorage.removeItem('gameConfig'); location.reload();
|
||||||
|
```
|
||||||
|
|
||||||
| Mini-Game | Status |
|
Vivaldi/Chrome: `Ctrl+Shift+R` (Hard Reload)
|
||||||
|-----------|--------|
|
|
||||||
| 🐍 Snake | ✅ Vollständig |
|
|
||||||
| 🐦 Flappy Bird | ✅ Vollständig |
|
|
||||||
| 🃏 Memory | ✅ Vollständig |
|
|
||||||
| ❓ Quiz | ✅ Vollständig (nutzt eigene Fragen aus Schritt 4) |
|
|
||||||
| ⚡ Reaktionstest | ✅ Vollständig |
|
|
||||||
| Alle anderen 7 | ⚠️ Platzhalter (zeigen Emoji + „bald verfügbar"-Button) |
|
|
||||||
|
|
||||||
#### Im Editor (`editor.html`) – Test-Popup (▶ TEST)
|
|
||||||
|
|
||||||
| Mini-Game | Status |
|
|
||||||
|-----------|--------|
|
|
||||||
| 🐍 Snake | ✅ Vollständig |
|
|
||||||
| 🐦 Flappy Bird | ✅ Vollständig |
|
|
||||||
| 🃏 Memory | ✅ Vollständig |
|
|
||||||
| ⚡ Reaktionstest | ✅ Vollständig |
|
|
||||||
| ❓ Quiz | ✅ Demo (nutzt eigene Fragen wenn vorhanden) |
|
|
||||||
| 🏀 Basketball | ✅ Vollständig |
|
|
||||||
| 🍎 Äpfel fangen | ✅ Vollständig |
|
|
||||||
| 🌀 Labyrinth | ✅ Vollständig |
|
|
||||||
| 🔴 Simon Says | ✅ Vollständig |
|
|
||||||
| ⌨️ Tipp-Rennen | ✅ Vollständig |
|
|
||||||
| 🧩 Rätsel | ⚠️ Stub (Platzhalter-Demo) |
|
|
||||||
| 🔍 Fehler finden | ⚠️ Stub (Platzhalter-Demo) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Bekannte offene Aufgaben
|
## Dateigrößen
|
||||||
|
|
||||||
### Hoch-Priorität
|
| Datei | Größe |
|
||||||
|
|-------|-------|
|
||||||
| Aufgabe | Beschreibung |
|
| `editor.html` | ~102 KB |
|
||||||
|---------|--------------|
|
| `game.html` | ~43 KB |
|
||||||
| 🎮 **7 Mini-Games im Spiel implementieren** | Basketball, Äpfel fangen, Labyrinth, Simon Says, Rätsel, Fehler finden, Tipp-Rennen sind im Editor testbar, aber in `game.html` noch Platzhalter |
|
| `codegen.js` | ~14 KB |
|
||||||
| 🔗 **URL-System / Veröffentlichung** | `doPublish()` zeigt nur Alert. Ziel: Config als Base64 in URL kodieren, QR-Code generieren, direkter GitHub-Pages-Link |
|
| `logo.png` | ~89 KB |
|
||||||
|
| `minigames/` (13 Dateien) | ~77 KB |
|
||||||
### Mittel-Priorität
|
| **Gesamt** | **~325 KB** |
|
||||||
|
|
||||||
| Aufgabe | Beschreibung |
|
|
||||||
|---------|--------------|
|
|
||||||
| 📱 **Mobile-Optimierung** | Drag-&-Drop auf Touch-Geräten (aktuell nur Maus) |
|
|
||||||
| 🔄 **Zurück-Navigation** | `window.scrollTo` scrollt ggf. falsch beim Schritt-Zurückspringen |
|
|
||||||
| 🧩 **Rätsel-Mini-Game** | Vollständiges Zahlen-Puzzle implementieren |
|
|
||||||
| 🔍 **Fehler-finden-Mini-Game** | Vollständige Implementierung (zwei Bilder, Unterschiede suchen) |
|
|
||||||
|
|
||||||
### Niedrige Priorität / Nice-to-have
|
|
||||||
|
|
||||||
| Aufgabe | Beschreibung |
|
|
||||||
|---------|--------------|
|
|
||||||
| 📦 **GitHub Pages Setup** | Repository anlegen, Dateien hochladen, Pages aktivieren |
|
|
||||||
| 🎨 **Mehr Figuren/Welten** | Aktuell 12+12; erweiterbar |
|
|
||||||
| 🌍 **Sprachoptionen** | Aktuell nur Deutsch |
|
|
||||||
| 🏆 **Highscore-System** | Optional für Mehrfach-Spielen |
|
|
||||||
| 📊 **Feedback-Auswertung exportieren** | Schritt 5 wertet aus, aber kein Export |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow-Empfehlungen
|
*Entwickelt im Workshop mit Claude (Anthropic) · Stand März 2026*
|
||||||
|
|
||||||
- Der Generator läuft vollständig **offline/lokal** – einfach `editor.html` im Browser öffnen
|
|
||||||
- **Reset:** Button „↺ Reset" oben links im Header, oder in der Browser-Konsole:
|
|
||||||
```javascript
|
|
||||||
localStorage.removeItem('gameConfig'); location.reload();
|
|
||||||
```
|
|
||||||
- **Cache leeren** in Vivaldi/Chrome: `Ctrl+Shift+R` (Hard Reload), oder DevTools öffnen → Network → „Disable cache"
|
|
||||||
- Bei Entwicklung: Syntax-Checks via Node.js möglich
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technische Hinweise
|
|
||||||
|
|
||||||
- Der Python-Code im Split-Screen ist **didaktisch korrekt** aber nicht ausführbar (kein `boardgame_engine`-Modul existiert). Das ist bewusst – es geht ums Erleben des Programmierens.
|
|
||||||
- Der State (`ST`-Objekt) wird bei jeder Eingabe in `localStorage` gesichert.
|
|
||||||
- `game.html` wird via `window.open()` aus dem Editor geöffnet und liest aus `localStorage.gameConfig`.
|
|
||||||
- Das `roundRect`-Canvas-API hat einen Polyfill für ältere Browser.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Geschätzter Restaufwand
|
|
||||||
|
|
||||||
| Bereich | Aufwand |
|
|
||||||
|---------|---------|
|
|
||||||
| 7 Mini-Games in game.html fertig implementieren | ~3–5 Tage |
|
|
||||||
| URL/QR-Code Veröffentlichungs-System | ~1–2 Tage |
|
|
||||||
| Mobile Touch-Drag | ~1 Tag |
|
|
||||||
| GitHub Pages + Deployment | ~2–4 Stunden |
|
|
||||||
| Testing & Bugfixing für Workshop | ~1–2 Tage |
|
|
||||||
| **Gesamt** | **~6–10 Tage** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Stand: März 2026 · Entwicklung im Workshop mit Claude (Anthropic)*
|
|
||||||
|
|
|
||||||
311
codegen.js
Normal file
311
codegen.js
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
/**
|
||||||
|
* codegen.js
|
||||||
|
* Python Live-Code-Generator für den Split-Screen
|
||||||
|
* Wird von editor.html geladen
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Progressive unlock tracking ─────────────────────────────
|
||||||
|
const unlocked = new Set();
|
||||||
|
let lastKey = null; // which section was LAST interacted with → gets yellow highlight
|
||||||
|
|
||||||
|
function setCodeFocus(key){
|
||||||
|
unlocked.add(key);
|
||||||
|
lastKey = key;
|
||||||
|
scheduleCodeUpdate(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus listener for text inputs
|
||||||
|
document.addEventListener('focusin', e=>{
|
||||||
|
const id = e.target.id || '';
|
||||||
|
if(id==='s1devname') setCodeFocus('dev');
|
||||||
|
if(id==='s1name') setCodeFocus('gamename');
|
||||||
|
if(id==='s1desc') setCodeFocus('gamedesc');
|
||||||
|
if(id && id.startsWith('qq')) setCodeFocus('quiz');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
function buildPythonCode(){
|
||||||
|
const s = ST;
|
||||||
|
const fig = FIGURES.find(f=>f.id===s.figure);
|
||||||
|
const bg = BACKGROUNDS.find(b=>b.id===s.background);
|
||||||
|
|
||||||
|
// Empty until first interaction
|
||||||
|
if(unlocked.size===0 && !s.devName && !s.name && !s.figure) return [];
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
// hi() returns true only for the last-touched key
|
||||||
|
const hi = key => lastKey === key;
|
||||||
|
|
||||||
|
// Header + imports — always shown once anything unlocked
|
||||||
|
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
|
||||||
|
lines.push({t:'cm', v:'# boardgame.py — Spiel-Generator PH Weingarten'});
|
||||||
|
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'cm', v:'# Module importieren'});
|
||||||
|
lines.push({t:'kw', v:'import', rest:' boardgame_engine as engine'});
|
||||||
|
lines.push({t:'kw', v:'import', rest:' minigames'});
|
||||||
|
lines.push({t:'kw', v:'from', rest:' config import GameConfig, Field, StoryText'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
|
||||||
|
// DEVELOPER
|
||||||
|
if(unlocked.has('dev') || s.devName){
|
||||||
|
lines.push({t:'cm', v:'# Wer hat dieses Spiel programmiert?'});
|
||||||
|
if(s.devName)
|
||||||
|
lines.push({t:'ass', var:'developer_name', val:`"${s.devName}"`, hi:hi('dev')});
|
||||||
|
else
|
||||||
|
lines.push({t:'ph', var:'developer_name', hint:'← tippe deinen Namen'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GAME NAME
|
||||||
|
if(unlocked.has('gamename') || s.name){
|
||||||
|
lines.push({t:'cm', v:'# Wie heißt das Spiel?'});
|
||||||
|
if(s.name)
|
||||||
|
lines.push({t:'ass', var:'game_name', val:`"${s.name}"`, hi:hi('gamename')});
|
||||||
|
else
|
||||||
|
lines.push({t:'ph', var:'game_name', hint:'← tippe den Spielnamen'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DESCRIPTION
|
||||||
|
if(unlocked.has('gamedesc') || s.desc){
|
||||||
|
lines.push({t:'cm', v:'# Kurze Beschreibung des Spiels'});
|
||||||
|
if(s.desc)
|
||||||
|
lines.push({t:'ass', var:'game_description', val:`"${s.desc}"`, hi:hi('gamedesc')});
|
||||||
|
else
|
||||||
|
lines.push({t:'ph', var:'game_description', hint:'← tippe eine Beschreibung'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIGURE
|
||||||
|
if(unlocked.has('figure') || s.figure){
|
||||||
|
lines.push({t:'cm', v:'# Spielfigur auswählen'});
|
||||||
|
if(s.figure)
|
||||||
|
lines.push({t:'ass', var:'player_figure',
|
||||||
|
val:`"${s.figure}" # ${fig?fig.e+' '+fig.n:''}`, hi:hi('figure')});
|
||||||
|
else
|
||||||
|
lines.push({t:'ph', var:'player_figure', hint:'← klicke eine Figur an'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BACKGROUND
|
||||||
|
if(unlocked.has('background') || s.background){
|
||||||
|
lines.push({t:'cm', v:'# Spielwelt / Hintergrund'});
|
||||||
|
if(s.background)
|
||||||
|
lines.push({t:'ass', var:'world_setting',
|
||||||
|
val:`"${s.background}" # ${bg?bg.e+' '+bg.n:''}`, hi:hi('background')});
|
||||||
|
else
|
||||||
|
lines.push({t:'ph', var:'world_setting', hint:'← klicke eine Welt an'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOVEMENT
|
||||||
|
if(unlocked.has('movement')){
|
||||||
|
lines.push({t:'cm', v:'# Wie viele Felder pro Runde?'});
|
||||||
|
lines.push({t:'ass', var:'movement_type',
|
||||||
|
val: s.rules.movement==='step' ? '"schritt" # immer genau 1 Feld'
|
||||||
|
: '"wuerfeln" # zufällig 1-6 Felder',
|
||||||
|
hi:hi('movement')});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAIL MODE
|
||||||
|
if(unlocked.has('failmode')){
|
||||||
|
lines.push({t:'cm', v:'# Was passiert wenn man verliert?'});
|
||||||
|
if(s.rules.fail==='lives'){
|
||||||
|
lines.push({t:'ass', var:'verlust_system', val:'"leben"', hi:hi('failmode')});
|
||||||
|
lines.push({t:'ass', var:'anzahl_leben', val:String(s.rules.lives||3), hi:hi('lives')});
|
||||||
|
} else {
|
||||||
|
lines.push({t:'ass', var:'verlust_system', val:'"punkte"', hi:hi('failmode')});
|
||||||
|
lines.push({t:'ass', var:'punkte_pro_sieg',val:String(s.rules.pts||10), hi:hi('pts')});
|
||||||
|
}
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVES / PTS sub-option
|
||||||
|
if(unlocked.has('lives') && s.rules.fail==='lives'){
|
||||||
|
// already rendered above, just make sure hi updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIELD COUNT
|
||||||
|
if(unlocked.has('fieldcount')){
|
||||||
|
lines.push({t:'cm', v:'# Wie groß ist das Spielfeld?'});
|
||||||
|
lines.push({t:'ass', var:'anzahl_felder', val:String(s.fieldCount), hi:hi('fieldcount')});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOARD SETUP — only once a game was dropped
|
||||||
|
const MG_PY ={snake:'schlangen_spiel',flappy:'flug_spiel',memory:'memory',quiz:'quiz',reaction:'reaktionstest',basketball:'basketball',catch:'fangen',maze:'labyrinth',simon:'simon_says',puzzle:'raetsel',spotdiff:'unterschiede',typing:'tipp_rennen'};
|
||||||
|
const MG_CM ={snake:'Schlange steuern',flappy:'Durch Hindernisse fliegen',memory:'Paare finden',quiz:'Frage beantworten',reaction:'Schnell reagieren',basketball:'Ball werfen',catch:'Objekte fangen',maze:'Ausweg finden',simon:'Sequenz merken',puzzle:'Rätsel lösen',spotdiff:'Unterschiede finden',typing:'Text eintippen'};
|
||||||
|
|
||||||
|
const hasAnyField = (s.fields||[]).some(Boolean);
|
||||||
|
if(unlocked.has('fields') || hasAnyField){
|
||||||
|
lines.push({t:'cm', v:'# Spielfeld aufbauen — jedes Feld wird definiert'});
|
||||||
|
lines.push({t:'fn-def', name:'setup_spielfeld', args:''});
|
||||||
|
lines.push({t:'ind', v:'felder = []'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'ind-cm', v:'# Startfeld (Index 0)'});
|
||||||
|
lines.push({t:'ind', v:'felder.append(Field(index=0, typ="start"))'});
|
||||||
|
|
||||||
|
for(let i=1;i<s.fieldCount-1;i++){
|
||||||
|
const fId=(s.fields||[])[i];
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
if(fId){
|
||||||
|
lines.push({t:'ind-cm', v:`# Feld ${i}: Mini-Game → ${MG_CM[fId]||fId}`});
|
||||||
|
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="minigame", spiel="${MG_PY[fId]||fId}"))`,
|
||||||
|
hi: hi('fields') && (s.fields||[])[i]===fId});
|
||||||
|
} else {
|
||||||
|
lines.push({t:'ind-cm', v:`# Feld ${i}: kein Spiel, einfach durchlaufen`});
|
||||||
|
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="leer"))`});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'ind-cm', v:`# Zielfeld (Index ${s.fieldCount-1}) 🏁`});
|
||||||
|
lines.push({t:'ind', v:`felder.append(Field(index=${s.fieldCount-1}, typ="ziel"))`});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'ind', v:'return felder'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MINIGAME FUNCTIONS
|
||||||
|
const usedGames=[...new Set((s.fields||[]).filter(Boolean))];
|
||||||
|
if(usedGames.length>0){
|
||||||
|
const MG_CM2={snake:'Schlange steuern ohne Wand zu treffen',flappy:'Durch alle Hindernisse fliegen',memory:'Alle Kartenpaare finden',quiz:'Die richtige Antwort wählen',reaction:'Schnell auf Signal drücken',basketball:'Ball zum richtigen Moment werfen',catch:'Fallende Objekte auffangen',maze:'Den Ausgang aus dem Labyrinth finden',simon:'Farbreihenfolge merken und wiederholen',puzzle:'Zahlen in die richtige Reihenfolge bringen',spotdiff:'Alle Unterschiede im Bild finden',typing:'Text so schnell wie möglich eintippen'};
|
||||||
|
lines.push({t:'cm', v:'# Für jedes Mini-Game gibt es eine eigene Funktion'});
|
||||||
|
usedGames.forEach(id=>{
|
||||||
|
const pyName=MG_PY[id]||id;
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'fn-def', name:`spiele_${pyName}`, args:'spieler'});
|
||||||
|
lines.push({t:'ind-cm', v:`# Aufgabe: ${MG_CM2[id]||id}`});
|
||||||
|
lines.push({t:'ind', v:`ergebnis = minigames.${pyName}.starten(spieler)`});
|
||||||
|
lines.push({t:'ind', v:`return ergebnis.gewonnen`});
|
||||||
|
});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// QUIZ
|
||||||
|
const quizItems=(s.quizData||[]).filter(q=>q&&q.question);
|
||||||
|
if(quizItems.length>0 || unlocked.has('quiz')){
|
||||||
|
lines.push({t:'cm', v:'# Quiz-Fragen für das Spiel'});
|
||||||
|
lines.push({t:'ass', var:'fragen', val:'[', hi:false});
|
||||||
|
quizItems.forEach(q=>{
|
||||||
|
const clean=q.question.replace(/"/g,"'").slice(0,50);
|
||||||
|
const ans=q.answers.map(a=>`"${(a||'').replace(/"/g,"'")}"`).join(', ');
|
||||||
|
const cor=q.correct!=null?`"${(q.answers[q.correct]||'').replace(/"/g,"'")}"`:'None';
|
||||||
|
lines.push({t:'ind', v:`{"frage": "${clean}", "antworten": [${ans}], "richtig": ${cor}},`});
|
||||||
|
});
|
||||||
|
if(quizItems.length===0) lines.push({t:'ind-cm', v:'# noch keine Fragen eingetragen...'});
|
||||||
|
lines.push({t:'ass-end', v:']'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAIN BLOCK
|
||||||
|
lines.push({t:'cm', v:'# ── Spiel starten ───────────────────────────'});
|
||||||
|
lines.push({t:'kw', v:'if', rest:" __name__ == '__main__':"});
|
||||||
|
lines.push({t:'ind-cm', v:'# Konfiguration zusammenstellen und Spiel starten'});
|
||||||
|
lines.push({t:'ind', v:'config = GameConfig('});
|
||||||
|
lines.push({t:'ind2', v:`name=${s.name?`"${s.name}"`:'""'},`});
|
||||||
|
lines.push({t:'ind2', v:`developer=${s.devName?`"${s.devName}"`:'""'},`});
|
||||||
|
if(s.figure) lines.push({t:'ind2', v:`figure="${s.figure}",`});
|
||||||
|
if(s.background) lines.push({t:'ind2', v:`setting="${s.background}",`});
|
||||||
|
if(hasAnyField) lines.push({t:'ind2', v:'board=setup_spielfeld(),'});
|
||||||
|
if(quizItems.length) lines.push({t:'ind2', v:'questions=fragen,'});
|
||||||
|
lines.push({t:'ind', v:')'});
|
||||||
|
lines.push({t:'bl'});
|
||||||
|
lines.push({t:'ind', v:'engine.run(config)'});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToken(line){
|
||||||
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
let r='';
|
||||||
|
switch(line.t){
|
||||||
|
case 'cm': r=`<span class="py-cm">${esc(line.v)}</span>`; break;
|
||||||
|
case 'bl': return '';
|
||||||
|
case 'ph': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-cm">"???" <span style="color:#e06c75;font-style:italic">${esc(line.hint||'')}</span></span>`; break;
|
||||||
|
case 'kw': r=`<span class="py-kw">${esc(line.v)}</span><span class="py-val">${esc(line.rest||'')}</span>`; break;
|
||||||
|
case 'ass': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-str">${esc(String(line.val))}</span>`; break;
|
||||||
|
case 'ass-end':r=`<span class="py-punc">${esc(line.v)}</span>`; break;
|
||||||
|
case 'fn-def': r=`<span class="py-kw">def </span><span class="py-fn">${esc(line.name)}</span><span class="py-punc">(${esc(line.args||'')})</span><span class="py-punc">:</span>`; break;
|
||||||
|
case 'ind': r=` <span class="py-val">${esc(line.v)}</span>`; break;
|
||||||
|
case 'ind2': r=` <span class="py-val">${esc(line.v)}</span>`; break;
|
||||||
|
case 'ind-cm': r=` <span class="py-cm">${esc(line.v)}</span>`; break;
|
||||||
|
default: return esc(line.v||'');
|
||||||
|
}
|
||||||
|
return line.hi ? `<span class="py-hi">${r}</span>` : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
let codeTypingTimer = null;
|
||||||
|
let lastLineCount = 0;
|
||||||
|
let lastKey_scroll = null; // prevent scroll thrash
|
||||||
|
|
||||||
|
function updateCodePane(){
|
||||||
|
const lines = buildPythonCode();
|
||||||
|
const codeEl = document.getElementById('codeContent');
|
||||||
|
const numsEl = document.getElementById('codeLineNums');
|
||||||
|
const linesEl= document.getElementById('codeLines');
|
||||||
|
if(!codeEl) return;
|
||||||
|
|
||||||
|
// Filename
|
||||||
|
const dev = ST.devName ? ST.devName.toLowerCase().replace(/[^a-z0-9]/g,'_') : 'dev';
|
||||||
|
const gn = ST.name ? ST.name.toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20) : 'boardgame';
|
||||||
|
const fnEl = document.getElementById('codeFilename');
|
||||||
|
if(fnEl) fnEl.textContent = (ST.name||ST.devName) ? `${gn}_von_${dev}.py` : 'boardgame.py';
|
||||||
|
|
||||||
|
if(lines.length===0){
|
||||||
|
codeEl.innerHTML='<span class="py-cm"># Klicke ein Feld an — dein Code erscheint hier ✨</span>';
|
||||||
|
numsEl.innerHTML='<div>1</div>';
|
||||||
|
if(linesEl) linesEl.textContent='1 Zeile';
|
||||||
|
lastLineCount=0; return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html='', lineNum=1;
|
||||||
|
const lineNums=[];
|
||||||
|
lines.forEach(line=>{
|
||||||
|
if(line.t==='bl'){ html+='\n'; }
|
||||||
|
else { html+=renderToken(line)+'\n'; }
|
||||||
|
lineNums.push(lineNum++);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCount = lines.length;
|
||||||
|
const grew = newCount > lastLineCount;
|
||||||
|
const prevCount= lastLineCount;
|
||||||
|
lastLineCount = newCount;
|
||||||
|
|
||||||
|
codeEl.innerHTML = html;
|
||||||
|
numsEl.innerHTML = lineNums.map(n=>`<div>${n}</div>`).join('');
|
||||||
|
if(linesEl) linesEl.textContent = lineNum+' Zeilen';
|
||||||
|
|
||||||
|
const status = document.getElementById('codeStatus');
|
||||||
|
if(status){ status.textContent='● Live'; setTimeout(()=>{ status.textContent='● Bereit'; },800); }
|
||||||
|
|
||||||
|
// Scroll: only when new lines appeared AND key changed (avoid thrash while typing)
|
||||||
|
const scroll = document.getElementById('codeScroll');
|
||||||
|
if(scroll && grew && lastKey !== lastKey_scroll){
|
||||||
|
lastKey_scroll = lastKey;
|
||||||
|
// Find the first highlighted line's position
|
||||||
|
setTimeout(()=>{
|
||||||
|
const hiEl = codeEl.querySelector('.py-hi');
|
||||||
|
if(hiEl){
|
||||||
|
const pre = codeEl;
|
||||||
|
const preTop = pre.getBoundingClientRect().top;
|
||||||
|
const hiTop = hiEl.getBoundingClientRect().top;
|
||||||
|
const offset = hiTop - preTop;
|
||||||
|
const scrollTarget = scroll.scrollTop + offset - 80;
|
||||||
|
scroll.scrollTo({top: Math.max(0, scrollTarget), behavior:'smooth'});
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCodeUpdate(delay=80){
|
||||||
|
clearTimeout(codeTypingTimer);
|
||||||
|
codeTypingTimer = setTimeout(updateCodePane, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('input', ()=>scheduleCodeUpdate(50));
|
||||||
|
document.addEventListener('change', ()=>scheduleCodeUpdate(50));
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
setTimeout(updateCodePane, 200);
|
||||||
2344
editor.html
2344
editor.html
File diff suppressed because it is too large
Load diff
35
game.html
35
game.html
|
|
@ -360,6 +360,7 @@ canvas#bgCanvas{
|
||||||
<div id="transOverlay"></div>
|
<div id="transOverlay"></div>
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
/* roundRect polyfill for older browsers */
|
/* roundRect polyfill for older browsers */
|
||||||
if(!CanvasRenderingContext2D.prototype.roundRect){
|
if(!CanvasRenderingContext2D.prototype.roundRect){
|
||||||
|
|
@ -374,7 +375,39 @@ if(!CanvasRenderingContext2D.prototype.roundRect){
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════ CONFIG ══════════ */
|
/* ══════════ CONFIG ══════════ */
|
||||||
const cfg = JSON.parse(localStorage.getItem('gameConfig') || '{"name":"Mein Spiel","desc":"Ein tolles Brettspiel!","figure":"robot","background":"space","fieldCount":10,"fields":["snake","flappy","quiz","memory","reaction",null,"snake","quiz",null,""],"rules":{"movement":"dice","fail":"lives","lives":"3","pts":"10"}}');
|
// 1. Versuche URL-Hash (geteilter Link), 2. Fallback localStorage, 3. Demo-Config
|
||||||
|
function loadGameConfig() {
|
||||||
|
const hash = window.location.hash.slice(1); // '#' entfernen
|
||||||
|
if (hash) {
|
||||||
|
try {
|
||||||
|
if (hash.startsWith('z:')) {
|
||||||
|
// Komprimiert: Base64 → binary → pako.inflate → JSON
|
||||||
|
const b64 = hash.slice(2);
|
||||||
|
const binary = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
const json = pako.inflate(bytes, { to: 'string' });
|
||||||
|
return JSON.parse(json);
|
||||||
|
} else if (hash.startsWith('j:')) {
|
||||||
|
// Unkomprimiert Base64
|
||||||
|
return JSON.parse(decodeURIComponent(escape(atob(hash.slice(2)))));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('URL-Hash konnte nicht gelesen werden:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: localStorage (vom Editor)
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('gameConfig');
|
||||||
|
if (stored) return JSON.parse(stored);
|
||||||
|
} catch(e) {}
|
||||||
|
// Demo-Config
|
||||||
|
return {name:'Mein Spiel',desc:'Ein tolles Brettspiel!',figure:'robot',
|
||||||
|
background:'space',fieldCount:10,
|
||||||
|
fields:['snake','flappy','quiz','memory','reaction',null,'snake','quiz',null,''],
|
||||||
|
rules:{movement:'dice',fail:'lives',lives:'3',pts:'10'}};
|
||||||
|
}
|
||||||
|
const cfg = loadGameConfig();
|
||||||
const fields = (cfg.fields||[]).map(f=>f||null);
|
const fields = (cfg.fields||[]).map(f=>f||null);
|
||||||
const fieldCount = cfg.fieldCount || fields.length || 10;
|
const fieldCount = cfg.fieldCount || fields.length || 10;
|
||||||
const rules = cfg.rules || {movement:'dice',fail:'lives',lives:'3',pts:'10'};
|
const rules = cfg.rules || {movement:'dice',fail:'lives',lives:'3',pts:'10'};
|
||||||
|
|
|
||||||
BIN
logo.png
Normal file
BIN
logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
93
minigames/_api.js
Normal file
93
minigames/_api.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* minigames/_api.js
|
||||||
|
* Gemeinsame API und Hilfsfunktionen für alle Mini-Games
|
||||||
|
*
|
||||||
|
* Jedes Mini-Game muss folgendes exportieren (als globale Variable window.MG_<NAME>):
|
||||||
|
*
|
||||||
|
* window.MG_snake = {
|
||||||
|
* id: 'snake',
|
||||||
|
* emoji: '🐍',
|
||||||
|
* name: 'Snake',
|
||||||
|
* desc: 'Steuere die Schlange...',
|
||||||
|
* controls: 'Pfeiltasten oder WASD',
|
||||||
|
* multi: 1, // 1 = einmalig, 3 = max 3x, 99 = unbegrenzt
|
||||||
|
* launch: function(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||||
|
* preview: function(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* launch() → vollständiges Spiel, wird in game.html verwendet
|
||||||
|
* preview() → kompakte Demo, wird im Editor-Test-Popup verwendet
|
||||||
|
* Beide geben ein Objekt { stop() } zurück zum Aufräumen.
|
||||||
|
*
|
||||||
|
* cfg = { quizData, theme, rules, devName, gameName }
|
||||||
|
*/
|
||||||
|
|
||||||
|
window.MGAPI = (function() {
|
||||||
|
|
||||||
|
// ── Canvas-Setup-Helfer ──────────────────────────────────────────────────
|
||||||
|
function makeCanvas(wrap, W, H) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
canvas.style.margin = '0 auto';
|
||||||
|
canvas.style.borderRadius = '12px';
|
||||||
|
canvas.style.background = '#0f0e17';
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
return { canvas, ctx: canvas.getContext('2d') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ergebnis-Anzeige (wird von game.html überschrieben) ──────────────────
|
||||||
|
// game.html setzt window.MGAPI.onResult = finishMinigame
|
||||||
|
// editor.html setzt window.MGAPI.onResult = previewResult
|
||||||
|
function onResult(won) {
|
||||||
|
if (typeof window._mgOnResult === 'function') {
|
||||||
|
window._mgOnResult(won);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schrift-Helfer ───────────────────────────────────────────────────────
|
||||||
|
function text(ctx, str, x, y, opts = {}) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${opts.weight || 'bold'} ${opts.size || 16}px ${opts.family || 'Nunito,sans-serif'}`;
|
||||||
|
ctx.fillStyle = opts.color || '#fff';
|
||||||
|
ctx.textAlign = opts.align || 'center';
|
||||||
|
ctx.textBaseline = opts.baseline || 'middle';
|
||||||
|
if (opts.shadow) {
|
||||||
|
ctx.shadowColor = opts.shadow;
|
||||||
|
ctx.shadowBlur = opts.shadowBlur || 10;
|
||||||
|
}
|
||||||
|
ctx.fillText(str, x, y);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runde Rechtecke ──────────────────────────────────────────────────────
|
||||||
|
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.arcTo(x, y, x + r, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||||
|
if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game-Over / Win Screen ───────────────────────────────────────────────
|
||||||
|
function resultScreen(ctx, W, H, won, msg) {
|
||||||
|
ctx.fillStyle = won ? 'rgba(16,185,129,0.85)' : 'rgba(239,68,68,0.85)';
|
||||||
|
roundRect(ctx, W/2-120, H/2-50, 240, 100, 16, ctx.fillStyle, null);
|
||||||
|
text(ctx, won ? '🎉 Gewonnen!' : '💀 Verloren!', W/2, H/2-16,
|
||||||
|
{ size: 22, weight: 'bold', family: "'Fredoka One',cursive", color: '#fff' });
|
||||||
|
if (msg) text(ctx, msg, W/2, H/2+16, { size: 13, color: 'rgba(255,255,255,0.85)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Öffentliche API ──────────────────────────────────────────────────────
|
||||||
|
return { makeCanvas, onResult, text, roundRect, resultScreen };
|
||||||
|
|
||||||
|
})();
|
||||||
190
minigames/basketball.js
Normal file
190
minigames/basketball.js
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* minigames/basketball.js
|
||||||
|
* 🏀 Basketball — Wirf den Ball zum richtigen Zeitpunkt!
|
||||||
|
*/
|
||||||
|
window.MG_basketball = (function() {
|
||||||
|
|
||||||
|
const ID = 'basketball';
|
||||||
|
const EMOJI = '🏀';
|
||||||
|
const NAME = 'Basketball';
|
||||||
|
const DESC = 'Drücke zum richtigen Zeitpunkt, um den Ball zu werfen!';
|
||||||
|
const CONTROLS = 'Klick / Leertaste';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#f97316' };
|
||||||
|
const WIN_BASKETS = cfg.winBaskets || 2;
|
||||||
|
|
||||||
|
// Korb-Position
|
||||||
|
const HOOP_X = W * 0.72;
|
||||||
|
const HOOP_Y = H * 0.28;
|
||||||
|
const HOOP_W = 54;
|
||||||
|
const BALL_R = 18;
|
||||||
|
const BALL_START = { x: W * 0.18, y: H * 0.72 };
|
||||||
|
|
||||||
|
let ballX = BALL_START.x;
|
||||||
|
let ballY = BALL_START.y;
|
||||||
|
let vx = 0, vy = 0;
|
||||||
|
let flying = false;
|
||||||
|
let score = 0;
|
||||||
|
let misses = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer;
|
||||||
|
let result = null; // 'win' | 'lose' | null
|
||||||
|
|
||||||
|
// Powerbar (zeigt an wie weit der Balken)
|
||||||
|
let power = 0;
|
||||||
|
let powerDir = 1;
|
||||||
|
let powerMax = 100;
|
||||||
|
|
||||||
|
function shoot() {
|
||||||
|
if (flying || result) return;
|
||||||
|
// Wurfkraft aus power (0–100), Winkel Richtung Korb
|
||||||
|
const dx = HOOP_X - BALL_START.x;
|
||||||
|
const dy = HOOP_Y - BALL_START.y - 20;
|
||||||
|
const speed = 4 + power * 0.09;
|
||||||
|
const angle = Math.atan2(dy, dx) - 0.18 - (power - 50) * 0.005;
|
||||||
|
vx = Math.cos(angle) * speed;
|
||||||
|
vy = Math.sin(angle) * speed;
|
||||||
|
flying = true;
|
||||||
|
ballX = BALL_START.x;
|
||||||
|
ballY = BALL_START.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAction = e => {
|
||||||
|
if (e.type === 'keydown' && e.code !== 'Space') return;
|
||||||
|
if (e.type === 'keydown') e.preventDefault();
|
||||||
|
shoot();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onAction);
|
||||||
|
canvas.addEventListener('click', onAction);
|
||||||
|
canvas.addEventListener('touchstart', e => { e.preventDefault(); shoot(); }, { passive: false });
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
flying = false;
|
||||||
|
ballX = BALL_START.x;
|
||||||
|
ballY = BALL_START.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHoop() {
|
||||||
|
// Brett
|
||||||
|
ctx.fillStyle = '#e5e7eb';
|
||||||
|
ctx.fillRect(HOOP_X + HOOP_W * 0.4, HOOP_Y - 50, 8, 50);
|
||||||
|
|
||||||
|
// Korb-Ring
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(HOOP_X + HOOP_W / 2, HOOP_Y, HOOP_W / 2, 8, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Netz (einfach als Linien)
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const nx = HOOP_X + (HOOP_W / 4) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(nx, HOOP_Y + 8);
|
||||||
|
ctx.lineTo(HOOP_X + HOOP_W / 2 + (i - 2) * 3, HOOP_Y + 36);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPower() {
|
||||||
|
if (flying || result) return;
|
||||||
|
const bx = 12, by = H - 28, bw = W * 0.45, bh = 14;
|
||||||
|
MGAPI.roundRect(ctx, bx, by, bw, bh, 6, 'rgba(255,255,255,0.1)', 'rgba(255,255,255,0.2)');
|
||||||
|
const fill = Math.max(0, Math.min(1, power / powerMax));
|
||||||
|
const color = fill < 0.4 ? '#22c55e' : fill < 0.7 ? '#f59e0b' : '#ef4444';
|
||||||
|
MGAPI.roundRect(ctx, bx + 2, by + 2, (bw - 4) * fill, bh - 4, 4, color, null);
|
||||||
|
MGAPI.text(ctx, 'Kraft', bx + bw / 2, by + bh / 2, { size: 10, color: '#fff' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(ts) {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Boden
|
||||||
|
MGAPI.roundRect(ctx, 0, H - 10, W, 10, 0, '#1e293b', null);
|
||||||
|
|
||||||
|
drawHoop();
|
||||||
|
|
||||||
|
if (!flying && !result) {
|
||||||
|
// Power-Bar animieren
|
||||||
|
power += powerDir * 1.8;
|
||||||
|
if (power >= powerMax) powerDir = -1;
|
||||||
|
if (power <= 0) powerDir = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flying) {
|
||||||
|
vy += 0.38; // Schwerkraft
|
||||||
|
ballX += vx;
|
||||||
|
ballY += vy;
|
||||||
|
|
||||||
|
// Korb-Treffer prüfen
|
||||||
|
const inHoopX = ballX > HOOP_X && ballX < HOOP_X + HOOP_W;
|
||||||
|
const inHoopY = Math.abs(ballY - HOOP_Y) < 18;
|
||||||
|
if (inHoopX && inHoopY && vy > 0) {
|
||||||
|
score++;
|
||||||
|
if (score >= WIN_BASKETS && !endTimer)
|
||||||
|
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 300);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boden oder aus dem Bild
|
||||||
|
if (ballY > H + 20 || ballX > W + 20 || ballX < -20) {
|
||||||
|
misses++;
|
||||||
|
if (misses >= 4 && score < WIN_BASKETS && !endTimer)
|
||||||
|
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 300);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ball
|
||||||
|
ctx.shadowColor = theme.primary;
|
||||||
|
ctx.shadowBlur = 15;
|
||||||
|
ctx.fillStyle = theme.primary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Linien auf Ball
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(ballX - BALL_R, ballY); ctx.lineTo(ballX + BALL_R, ballY); ctx.stroke();
|
||||||
|
|
||||||
|
drawPower();
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🏀 ${score} / ${WIN_BASKETS}`, W / 2, 18, { size: 14, color: theme.primary });
|
||||||
|
if (!flying && !result)
|
||||||
|
MGAPI.text(ctx, 'Klick oder Leertaste zum Werfen', W / 2, H - 48, { size: 11, color: 'rgba(255,255,255,0.45)' });
|
||||||
|
|
||||||
|
if (result)
|
||||||
|
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||||
|
result === 'win' ? `${score} Körbe getroffen!` : `Nur ${score} — weiter üben!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onAction);
|
||||||
|
canvas.removeEventListener('click', onAction);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
158
minigames/catch.js
Normal file
158
minigames/catch.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
/**
|
||||||
|
* minigames/catch.js
|
||||||
|
* 🍎 Äpfel fangen — Bewege den Korb und fange fallende Früchte!
|
||||||
|
*/
|
||||||
|
window.MG_catch = (function() {
|
||||||
|
|
||||||
|
const ID = 'catch';
|
||||||
|
const EMOJI = '🍎';
|
||||||
|
const NAME = 'Äpfel fangen';
|
||||||
|
const DESC = 'Bewege den Korb mit ← → oder der Maus und fange 5 Äpfel!';
|
||||||
|
const CONTROLS = '← → / A D / Maus';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#22c55e' };
|
||||||
|
const WIN = cfg.winCatch || 5;
|
||||||
|
const FRUITS = ['🍎','🍊','🍋','🍇','🍓','🍑'];
|
||||||
|
const BASKET_W = 70, BASKET_H = 24;
|
||||||
|
|
||||||
|
let basket = { x: W / 2 - BASKET_W / 2, speed: 0 };
|
||||||
|
let items = [];
|
||||||
|
let score = 0;
|
||||||
|
let missed = 0;
|
||||||
|
let frame = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer, result = null;
|
||||||
|
|
||||||
|
const keys = {};
|
||||||
|
let mouseX = null;
|
||||||
|
|
||||||
|
const onKey = e => { keys[e.code] = e.type === 'keydown'; };
|
||||||
|
const onMouse = e => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = (e.clientX - rect.left) * (canvas.width / rect.width) - BASKET_W / 2;
|
||||||
|
};
|
||||||
|
const onTouch = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = (e.touches[0].clientX - rect.left) * (canvas.width / rect.width) - BASKET_W / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.addEventListener('keyup', onKey);
|
||||||
|
canvas.addEventListener('mousemove', onMouse);
|
||||||
|
canvas.addEventListener('touchmove', onTouch, { passive: false });
|
||||||
|
|
||||||
|
function spawnItem() {
|
||||||
|
items.push({
|
||||||
|
x: Math.random() * (W - 40) + 20,
|
||||||
|
y: -30,
|
||||||
|
vy: 1.8 + Math.random() * 1.4 + score * 0.08,
|
||||||
|
emoji: FRUITS[Math.floor(Math.random() * FRUITS.length)],
|
||||||
|
size: 26 + Math.random() * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
frame++;
|
||||||
|
|
||||||
|
// Basket bewegen
|
||||||
|
if (mouseX !== null) {
|
||||||
|
basket.x += (mouseX - basket.x) * 0.18;
|
||||||
|
} else {
|
||||||
|
if (keys['ArrowLeft'] || keys['KeyA']) basket.x -= 5.5;
|
||||||
|
if (keys['ArrowRight'] || keys['KeyD']) basket.x += 5.5;
|
||||||
|
}
|
||||||
|
basket.x = Math.max(0, Math.min(W - BASKET_W, basket.x));
|
||||||
|
|
||||||
|
// Früchte spawnen
|
||||||
|
const spawnRate = Math.max(30, 70 - score * 3);
|
||||||
|
if (frame % spawnRate === 0 && !result) spawnItem();
|
||||||
|
|
||||||
|
// Update
|
||||||
|
items.forEach(it => { it.y += it.vy; });
|
||||||
|
|
||||||
|
// Fangen / Vermissen
|
||||||
|
items = items.filter(it => {
|
||||||
|
const basketTop = H - 50 - BASKET_H;
|
||||||
|
if (it.y + it.size / 2 > basketTop &&
|
||||||
|
it.x > basket.x && it.x < basket.x + BASKET_W) {
|
||||||
|
score++;
|
||||||
|
if (score >= WIN && !endTimer)
|
||||||
|
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 200);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (it.y > H + 20) {
|
||||||
|
missed++;
|
||||||
|
if (missed >= 4 && score < WIN && !endTimer)
|
||||||
|
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 200);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Zeichnen ──
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Boden-Linie
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, H - 50); ctx.lineTo(W, H - 50); ctx.stroke();
|
||||||
|
|
||||||
|
// Früchte
|
||||||
|
ctx.font = '28px serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
items.forEach(it => ctx.fillText(it.emoji, it.x, it.y + it.size));
|
||||||
|
|
||||||
|
// Korb
|
||||||
|
const bx = basket.x, by = H - 50 - BASKET_H;
|
||||||
|
MGAPI.roundRect(ctx, bx, by, BASKET_W, BASKET_H, 6, `${theme.primary}33`, theme.primary);
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
// Korb-Linien
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
ctx.strokeStyle = `${theme.primary}66`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx + (BASKET_W / 4) * i, by);
|
||||||
|
ctx.lineTo(bx + (BASKET_W / 4) * i, by + BASKET_H);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.font = '22px serif';
|
||||||
|
ctx.fillText('🧺', bx + BASKET_W / 2, by + BASKET_H + 20);
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🍎 ${score} / ${WIN} 💔 ${missed} / 4`, W / 2, 18, { size: 13, color: theme.primary });
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
MGAPI.text(ctx, '← → oder Maus bewegen', W / 2, H - 10, { size: 10, color: 'rgba(255,255,255,0.3)' });
|
||||||
|
|
||||||
|
if (result)
|
||||||
|
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||||
|
result === 'win' ? `${score} Früchte gefangen!` : `${missed} verpasst — nächstes Mal!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.removeEventListener('keyup', onKey);
|
||||||
|
canvas.removeEventListener('mousemove', onMouse);
|
||||||
|
canvas.removeEventListener('touchmove', onTouch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
129
minigames/flappy.js
Normal file
129
minigames/flappy.js
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* minigames/flappy.js
|
||||||
|
* 🐦 Flappy Bird — Klicke oder Leertaste, um durch die Röhren zu fliegen!
|
||||||
|
*/
|
||||||
|
window.MG_flappy = (function() {
|
||||||
|
|
||||||
|
const ID = 'flappy';
|
||||||
|
const EMOJI = '🐦';
|
||||||
|
const NAME = 'Flappy Bird';
|
||||||
|
const DESC = 'Klick oder Leertaste, um zu fliegen. 3 Hindernisse schaffen = Sieg!';
|
||||||
|
const CONTROLS = 'Klick / Leertaste';
|
||||||
|
const MULTI = 1;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#06b6d4' };
|
||||||
|
const WIN_PIPES = cfg.winPipes || 3;
|
||||||
|
const GRAV = 0.5, JUMP = -8, PW = 38, GAP = 85;
|
||||||
|
|
||||||
|
let bird = { y: H / 2, vy: 0 };
|
||||||
|
let pipes = [{ x: W, gap: Math.random() * (H - 100) + 30 }];
|
||||||
|
let score = 0;
|
||||||
|
let dead = false;
|
||||||
|
let started = false;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer;
|
||||||
|
|
||||||
|
const jump = () => { if (!dead) { bird.vy = JUMP; started = true; } };
|
||||||
|
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); jump(); } };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
canvas.addEventListener('click', jump);
|
||||||
|
// Touch
|
||||||
|
canvas.addEventListener('touchstart', e => { e.preventDefault(); jump(); }, { passive: false });
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Hintergrund-Gradient
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
||||||
|
grad.addColorStop(0, '#0a0a1a');
|
||||||
|
grad.addColorStop(1, '#050508');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
if (started && !dead) {
|
||||||
|
bird.vy += GRAV;
|
||||||
|
bird.y += bird.vy;
|
||||||
|
pipes.forEach(p => p.x -= 2.8);
|
||||||
|
if (pipes[pipes.length - 1].x < W - 170)
|
||||||
|
pipes.push({ x: W, gap: Math.random() * (H - 100) + 30 });
|
||||||
|
pipes = pipes.filter(p => p.x > -PW);
|
||||||
|
pipes.forEach(p => { if (p.x + PW < 50 && !p.passed) { p.passed = true; score++; } });
|
||||||
|
|
||||||
|
if (bird.y < 0 || bird.y + 20 > H) dead = true;
|
||||||
|
pipes.forEach(p => {
|
||||||
|
if (50 < p.x + PW && 70 > p.x && (bird.y < p.gap || bird.y + 20 > p.gap + GAP))
|
||||||
|
dead = true;
|
||||||
|
});
|
||||||
|
if (dead && !endTimer)
|
||||||
|
endTimer = setTimeout(() => onDone(score >= WIN_PIPES), 1000);
|
||||||
|
if (score >= WIN_PIPES && !dead && !endTimer)
|
||||||
|
endTimer = setTimeout(() => onDone(true), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Röhren zeichnen
|
||||||
|
pipes.forEach(p => {
|
||||||
|
// Röhren-Körper
|
||||||
|
MGAPI.roundRect(ctx, p.x, 0, PW, p.gap - 8, 4, `${theme.primary}cc`, null);
|
||||||
|
MGAPI.roundRect(ctx, p.x - 3, p.gap - 12, PW + 6, 12, 4, `${theme.primary}ee`, null);
|
||||||
|
MGAPI.roundRect(ctx, p.x, p.gap + GAP + 8, PW, H - p.gap - GAP - 8, 4, `${theme.primary}cc`, null);
|
||||||
|
MGAPI.roundRect(ctx, p.x - 3, p.gap + GAP, PW + 6, 12, 4, `${theme.primary}ee`, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vogel
|
||||||
|
ctx.shadowColor = dead ? '#ef4444' : theme.primary;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.fillStyle = dead ? '#ef4444' : theme.primary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(50 + 14, bird.y + 10, 14, 10, bird.vy * 0.04, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Auge
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(50 + 20, bird.y + 7, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(50 + 21, bird.y + 7, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🐦 ${score} / ${WIN_PIPES}`, 8, 14,
|
||||||
|
{ align: 'left', size: 13, color: theme.primary });
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
MGAPI.text(ctx, 'Klicken oder Leertaste', W / 2, H / 2 + 40,
|
||||||
|
{ size: 13, color: 'rgba(255,255,255,0.7)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dead) {
|
||||||
|
MGAPI.resultScreen(ctx, W, H, score >= WIN_PIPES,
|
||||||
|
score >= WIN_PIPES ? `${score} Hindernisse geschafft!` : `Nur ${score} — flieg weiter!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
canvas.removeEventListener('click', jump);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, { ...cfg, winPipes: 3 }, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
181
minigames/maze.js
Normal file
181
minigames/maze.js
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* minigames/maze.js
|
||||||
|
* 🌀 Labyrinth — Finde den Ausgang!
|
||||||
|
*/
|
||||||
|
window.MG_maze = (function() {
|
||||||
|
|
||||||
|
const ID = 'maze';
|
||||||
|
const EMOJI = '🌀';
|
||||||
|
const NAME = 'Labyrinth';
|
||||||
|
const DESC = 'Steuere mit den Pfeiltasten durch das Labyrinth zum Ausgang!';
|
||||||
|
const CONTROLS = 'Pfeiltasten / WASD';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
// ── Maze-Generator (Recursive Backtracker) ────────────────────────────────
|
||||||
|
function generateMaze(COLS, ROWS) {
|
||||||
|
const cells = Array.from({ length: ROWS }, () =>
|
||||||
|
Array.from({ length: COLS }, () => ({ n: true, s: true, e: true, w: true, visited: false }))
|
||||||
|
);
|
||||||
|
const stack = [];
|
||||||
|
let cur = { c: 0, r: 0 };
|
||||||
|
cells[0][0].visited = true;
|
||||||
|
stack.push(cur);
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const { c, r } = stack[stack.length - 1];
|
||||||
|
const neighbors = [];
|
||||||
|
if (r > 0 && !cells[r-1][c].visited) neighbors.push({ c, r: r-1, dir: 'n' });
|
||||||
|
if (r < ROWS-1 && !cells[r+1][c].visited) neighbors.push({ c, r: r+1, dir: 's' });
|
||||||
|
if (c < COLS-1 && !cells[r][c+1].visited) neighbors.push({ c: c+1, r, dir: 'e' });
|
||||||
|
if (c > 0 && !cells[r][c-1].visited) neighbors.push({ c: c-1, r, dir: 'w' });
|
||||||
|
|
||||||
|
if (!neighbors.length) { stack.pop(); continue; }
|
||||||
|
const next = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||||
|
cells[r][c][next.dir] = false;
|
||||||
|
const opp = { n:'s', s:'n', e:'w', w:'e' }[next.dir];
|
||||||
|
cells[next.r][next.c][opp] = false;
|
||||||
|
cells[next.r][next.c].visited = true;
|
||||||
|
stack.push({ c: next.c, r: next.r });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#6366f1' };
|
||||||
|
|
||||||
|
const COLS = 9, ROWS = 7;
|
||||||
|
const cellW = Math.floor((W - 16) / COLS);
|
||||||
|
const cellH = Math.floor((H - 40) / ROWS);
|
||||||
|
const OX = (W - COLS * cellW) / 2;
|
||||||
|
const OY = 32;
|
||||||
|
const WALL = 2;
|
||||||
|
|
||||||
|
const maze = generateMaze(COLS, ROWS);
|
||||||
|
let px = 0, py = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer, won = false;
|
||||||
|
let moveTimer = null;
|
||||||
|
|
||||||
|
function tryMove(dc, dr) {
|
||||||
|
const cell = maze[py][px];
|
||||||
|
const dir = dc === 1 ? 'e' : dc === -1 ? 'w' : dr === 1 ? 's' : 'n';
|
||||||
|
if (cell[dir]) return; // Wand
|
||||||
|
px += dc; py += dr;
|
||||||
|
if (px === COLS - 1 && py === ROWS - 1 && !endTimer) {
|
||||||
|
won = true;
|
||||||
|
endTimer = setTimeout(() => onDone(true), 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const held = {};
|
||||||
|
const onKey = e => {
|
||||||
|
const map = {
|
||||||
|
ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0],
|
||||||
|
w: [0,-1], s: [0,1], a: [-1,0], d: [1,0],
|
||||||
|
};
|
||||||
|
const d = map[e.key];
|
||||||
|
if (!d) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.type === 'keydown') {
|
||||||
|
if (!held[e.key]) tryMove(d[0], d[1]);
|
||||||
|
held[e.key] = true;
|
||||||
|
} else {
|
||||||
|
held[e.key] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch-Swipe
|
||||||
|
let touchStart = null;
|
||||||
|
canvas.addEventListener('touchstart', e => {
|
||||||
|
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}, { passive: true });
|
||||||
|
canvas.addEventListener('touchend', e => {
|
||||||
|
if (!touchStart) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStart.x;
|
||||||
|
const dy = e.changedTouches[0].clientY - touchStart.y;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
tryMove(dx > 0 ? 1 : -1, 0);
|
||||||
|
} else {
|
||||||
|
tryMove(0, dy > 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.addEventListener('keyup', onKey);
|
||||||
|
|
||||||
|
function drawMaze() {
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const x = OX + c * cellW;
|
||||||
|
const y = OY + r * cellH;
|
||||||
|
const cell = maze[r][c];
|
||||||
|
|
||||||
|
ctx.strokeStyle = `${theme.primary}88`;
|
||||||
|
ctx.lineWidth = WALL;
|
||||||
|
|
||||||
|
if (cell.n && r === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+cellW,y); ctx.stroke(); }
|
||||||
|
if (cell.w && c === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+cellH); ctx.stroke(); }
|
||||||
|
if (cell.s) { ctx.beginPath(); ctx.moveTo(x,y+cellH); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
|
||||||
|
if (cell.e) { ctx.beginPath(); ctx.moveTo(x+cellW,y); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Start & Ziel markieren
|
||||||
|
MGAPI.roundRect(ctx, OX, OY, cellW, cellH, 4, 'rgba(16,185,129,0.2)', null);
|
||||||
|
MGAPI.roundRect(ctx,
|
||||||
|
OX + (COLS-1) * cellW, OY + (ROWS-1) * cellH,
|
||||||
|
cellW, cellH, 4, 'rgba(245,166,35,0.25)', null);
|
||||||
|
|
||||||
|
drawMaze();
|
||||||
|
|
||||||
|
// Start/Ziel-Label
|
||||||
|
ctx.font = '14px serif'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('🟢', OX + cellW/2, OY + cellH/2 + 6);
|
||||||
|
ctx.fillText('🏁', OX + (COLS-0.5)*cellW, OY + (ROWS-0.5)*cellH + 6);
|
||||||
|
|
||||||
|
// Spieler
|
||||||
|
const plX = OX + px * cellW + cellW / 2;
|
||||||
|
const plY = OY + py * cellH + cellH / 2;
|
||||||
|
ctx.shadowColor = theme.primary;
|
||||||
|
ctx.shadowBlur = 14;
|
||||||
|
ctx.fillStyle = theme.primary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(plX, plY, Math.min(cellW, cellH) * 0.32, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🌀 Finde den Ausgang!`, W / 2, 16, { size: 12, color: theme.primary });
|
||||||
|
MGAPI.text(ctx, '← ↑ → ↓', W / 2, H - 10, { size: 10, color: 'rgba(255,255,255,0.3)' });
|
||||||
|
|
||||||
|
if (won)
|
||||||
|
MGAPI.resultScreen(ctx, W, H, true, 'Ausgang gefunden!');
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.removeEventListener('keyup', onKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
142
minigames/memory.js
Normal file
142
minigames/memory.js
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* minigames/memory.js
|
||||||
|
* 🃏 Memory — Finde alle Paare, so schnell wie möglich!
|
||||||
|
*/
|
||||||
|
window.MG_memory = (function() {
|
||||||
|
|
||||||
|
const ID = 'memory';
|
||||||
|
const EMOJI = '🃏';
|
||||||
|
const NAME = 'Memory';
|
||||||
|
const DESC = 'Finde alle Paare — so schnell wie möglich!';
|
||||||
|
const CONTROLS = 'Mausklick / Tippen';
|
||||||
|
const MULTI = 1;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#8b5cf6' };
|
||||||
|
const EMOJIS = cfg.emojis || ['🐱','🐶','🦊','🐸','🦋','🐠'];
|
||||||
|
|
||||||
|
const all = [...EMOJIS, ...EMOJIS].sort(() => Math.random() - 0.5);
|
||||||
|
let cards = all.map((e, i) => ({ e, i, open: false }));
|
||||||
|
let flipped = [], matched = [], checking = false, moves = 0;
|
||||||
|
let stopped = false, raf, startTime = null, elapsed = 0;
|
||||||
|
|
||||||
|
// Layout: 4×3
|
||||||
|
const COLS = 4, ROWS = Math.ceil(all.length / 4);
|
||||||
|
const PAD = 8;
|
||||||
|
const cw = Math.floor((W - PAD * (COLS + 1)) / COLS);
|
||||||
|
const ch = Math.floor((H - PAD * (ROWS + 2) - 24) / ROWS);
|
||||||
|
const ox = (W - COLS * cw - PAD * (COLS - 1)) / 2;
|
||||||
|
const oy = 30;
|
||||||
|
|
||||||
|
function cardAt(mx, my) {
|
||||||
|
for (let ri = 0; ri < ROWS; ri++) {
|
||||||
|
for (let ci = 0; ci < COLS; ci++) {
|
||||||
|
const idx = ri * COLS + ci;
|
||||||
|
if (idx >= cards.length) continue;
|
||||||
|
const x = ox + ci * (cw + PAD);
|
||||||
|
const y = oy + ri * (ch + PAD);
|
||||||
|
if (mx >= x && mx < x + cw && my >= y && my < y + ch) return cards[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e) {
|
||||||
|
if (checking) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
const mx = (e.clientX - rect.left) * scaleX;
|
||||||
|
const my = (e.clientY - rect.top) * scaleY;
|
||||||
|
const card = cardAt(mx, my);
|
||||||
|
if (!card || card.open || matched.includes(card.i)) return;
|
||||||
|
if (!startTime) startTime = performance.now();
|
||||||
|
|
||||||
|
card.open = true;
|
||||||
|
flipped.push(card);
|
||||||
|
if (flipped.length === 2) {
|
||||||
|
moves++;
|
||||||
|
checking = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (flipped[0].e === flipped[1].e) {
|
||||||
|
matched.push(flipped[0].i, flipped[1].i);
|
||||||
|
if (matched.length === cards.length) {
|
||||||
|
setTimeout(() => onDone(true), 600);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flipped.forEach(c => c.open = false);
|
||||||
|
}
|
||||||
|
flipped = [];
|
||||||
|
checking = false;
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('click', onClick);
|
||||||
|
|
||||||
|
// Karten-Flip-Animation (progress 0–1)
|
||||||
|
const flipAnim = new Map(); // card.i → { prog, dir }
|
||||||
|
|
||||||
|
function loop(ts) {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
if (startTime) elapsed = (performance.now() - startTime) / 1000;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🃏 ${matched.length / 2} / ${EMOJIS.length} | Züge: ${moves}`, W / 2, 16,
|
||||||
|
{ size: 12, color: theme.primary });
|
||||||
|
|
||||||
|
// Karten zeichnen
|
||||||
|
for (let ri = 0; ri < ROWS; ri++) {
|
||||||
|
for (let ci = 0; ci < COLS; ci++) {
|
||||||
|
const idx = ri * COLS + ci;
|
||||||
|
if (idx >= cards.length) continue;
|
||||||
|
const card = cards[idx];
|
||||||
|
const x = ox + ci * (cw + PAD);
|
||||||
|
const y = oy + ri * (ch + PAD);
|
||||||
|
const isM = matched.includes(card.i);
|
||||||
|
const isOpen = card.open || isM;
|
||||||
|
|
||||||
|
if (isM) {
|
||||||
|
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||||
|
'rgba(16,185,129,0.15)', '#10b981');
|
||||||
|
} else if (isOpen) {
|
||||||
|
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||||
|
`${theme.primary}22`, theme.primary);
|
||||||
|
} else {
|
||||||
|
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||||
|
'rgba(255,255,255,0.04)', 'rgba(255,255,255,0.1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
ctx.font = `${Math.min(cw, ch) * 0.55}px serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(card.e, x + cw / 2, y + ch / 2 + ch * 0.15);
|
||||||
|
} else {
|
||||||
|
MGAPI.text(ctx, '?', x + cw / 2, y + ch / 2,
|
||||||
|
{ size: Math.floor(ch * 0.35), color: 'rgba(255,255,255,0.2)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
canvas.removeEventListener('click', onClick);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
137
minigames/puzzle.js
Normal file
137
minigames/puzzle.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* minigames/puzzle.js
|
||||||
|
* 🧩 Zahlen-Puzzle — Schiebe die Kacheln in die richtige Reihenfolge! (15-Puzzle)
|
||||||
|
*/
|
||||||
|
window.MG_puzzle = (function() {
|
||||||
|
|
||||||
|
const ID = 'puzzle';
|
||||||
|
const EMOJI = '🧩';
|
||||||
|
const NAME = 'Zahlen-Puzzle';
|
||||||
|
const DESC = 'Schiebe die Kacheln in die richtige Reihenfolge (1–8)!';
|
||||||
|
const CONTROLS = 'Mausklick / Pfeiltasten';
|
||||||
|
const MULTI = 1;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#14b8a6' };
|
||||||
|
|
||||||
|
const SIZE = 3; // 3×3
|
||||||
|
const PAD = 20;
|
||||||
|
const cellW = Math.floor((W - PAD * 2) / SIZE);
|
||||||
|
const cellH = Math.floor((H - PAD * 2 - 30) / SIZE);
|
||||||
|
const OX = (W - SIZE * cellW) / 2;
|
||||||
|
const OY = 30 + (H - 30 - SIZE * cellH) / 2;
|
||||||
|
|
||||||
|
// Ziel: 1 2 3 / 4 5 6 / 7 8 _
|
||||||
|
const GOAL = [1,2,3,4,5,6,7,8,0];
|
||||||
|
let board = [...GOAL];
|
||||||
|
let moves = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer, solved = false;
|
||||||
|
|
||||||
|
// Mischeln (100 zufällige Züge)
|
||||||
|
function shuffle() {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const ei = board.indexOf(0);
|
||||||
|
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
|
||||||
|
const nbr = [];
|
||||||
|
if (er > 0) nbr.push(ei - SIZE);
|
||||||
|
if (er < SIZE-1) nbr.push(ei + SIZE);
|
||||||
|
if (ec > 0) nbr.push(ei - 1);
|
||||||
|
if (ec < SIZE-1) nbr.push(ei + 1);
|
||||||
|
const ni = nbr[Math.floor(Math.random() * nbr.length)];
|
||||||
|
[board[ei], board[ni]] = [board[ni], board[ei]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shuffle();
|
||||||
|
|
||||||
|
function slideAt(idx) {
|
||||||
|
if (solved) return;
|
||||||
|
const ei = board.indexOf(0);
|
||||||
|
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
|
||||||
|
const tr = Math.floor(idx / SIZE), tc = idx % SIZE;
|
||||||
|
const adj = (er === tr && Math.abs(ec - tc) === 1) ||
|
||||||
|
(ec === tc && Math.abs(er - tr) === 1);
|
||||||
|
if (!adj) return;
|
||||||
|
[board[ei], board[idx]] = [board[idx], board[ei]];
|
||||||
|
moves++;
|
||||||
|
if (board.join() === GOAL.join()) {
|
||||||
|
solved = true;
|
||||||
|
endTimer = setTimeout(() => onDone(true), 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('click', e => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||||
|
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||||
|
for (let i = 0; i < SIZE * SIZE; i++) {
|
||||||
|
const c = i % SIZE, r = Math.floor(i / SIZE);
|
||||||
|
const x = OX + c * cellW, y = OY + r * cellH;
|
||||||
|
if (mx >= x && mx < x + cellW - 4 && my >= y && my < y + cellH - 4)
|
||||||
|
slideAt(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKey = e => {
|
||||||
|
const ei = board.indexOf(0);
|
||||||
|
const map = { ArrowUp: SIZE, ArrowDown: -SIZE, ArrowLeft: 1, ArrowRight: -1 };
|
||||||
|
const di = map[e.key];
|
||||||
|
if (di && ei + di >= 0 && ei + di < SIZE * SIZE) {
|
||||||
|
e.preventDefault();
|
||||||
|
slideAt(ei + di);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
MGAPI.text(ctx, `🧩 Züge: ${moves}`, W / 2, 18, { size: 12, color: theme.primary });
|
||||||
|
|
||||||
|
for (let i = 0; i < SIZE * SIZE; i++) {
|
||||||
|
const val = board[i];
|
||||||
|
const c = i % SIZE, r = Math.floor(i / SIZE);
|
||||||
|
const x = OX + c * cellW, y = OY + r * cellH;
|
||||||
|
|
||||||
|
if (val === 0) {
|
||||||
|
// Leerfeld
|
||||||
|
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 8, 'rgba(255,255,255,0.03)', null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrect = val === GOAL[i];
|
||||||
|
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 10,
|
||||||
|
isCorrect ? `${theme.primary}33` : 'rgba(255,255,255,0.07)',
|
||||||
|
isCorrect ? theme.primary : 'rgba(255,255,255,0.15)');
|
||||||
|
|
||||||
|
MGAPI.text(ctx, String(val), x + cellW/2 - 2, y + cellH/2 + 2,
|
||||||
|
{ size: Math.floor(cellH * 0.38), family: "'Fredoka One',cursive",
|
||||||
|
color: isCorrect ? theme.primary : '#fff' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solved)
|
||||||
|
MGAPI.resultScreen(ctx, W, H, true, `In ${moves} Zügen gelöst!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
111
minigames/quiz.js
Normal file
111
minigames/quiz.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* minigames/quiz.js
|
||||||
|
* ❓ Quiz — Beantworte die Frage richtig!
|
||||||
|
*/
|
||||||
|
window.MG_quiz = (function() {
|
||||||
|
|
||||||
|
const ID = 'quiz';
|
||||||
|
const EMOJI = '❓';
|
||||||
|
const NAME = 'Quiz';
|
||||||
|
const DESC = 'Beantworte die Frage richtig!';
|
||||||
|
const CONTROLS = 'Mausklick / Tippen';
|
||||||
|
const MULTI = 99; // unbegrenzt, je Quiz-Feld eine Frage
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const theme = cfg.theme || { primary: '#f59e0b' };
|
||||||
|
const quizData = cfg.quizData || [];
|
||||||
|
const fieldIdx = cfg.fieldIndex ?? null;
|
||||||
|
|
||||||
|
// Passende Frage suchen
|
||||||
|
let q = null;
|
||||||
|
if (fieldIdx !== null) q = quizData.find(d => d.fieldIndex === fieldIdx);
|
||||||
|
if (!q && quizData.length > 0) q = quizData[Math.floor(Math.random() * quizData.length)];
|
||||||
|
|
||||||
|
// Fallback wenn keine Frage hinterlegt
|
||||||
|
if (!q || !q.question) {
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div style="text-align:center;padding:40px 20px;font-family:'Nunito',sans-serif">
|
||||||
|
<div style="font-size:3rem;margin-bottom:12px">❓</div>
|
||||||
|
<div style="color:#a7a3c2;font-size:14px;margin-bottom:20px">Keine Quiz-Frage für dieses Feld hinterlegt.</div>
|
||||||
|
<button onclick="MGAPI.onResult(true)"
|
||||||
|
style="background:linear-gradient(135deg,#7c3aed,#f5a623);color:#fff;font-family:'Fredoka One',cursive;
|
||||||
|
font-size:1rem;border:none;border-radius:10px;padding:12px 28px;cursor:pointer">
|
||||||
|
✅ Trotzdem bestanden!
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
return { stop() {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML-Quiz (kein Canvas — besser lesbar)
|
||||||
|
const answers = q.answers || [];
|
||||||
|
const correct = q.correct ?? 0;
|
||||||
|
const COLORS = ['#7c3aed','#2563eb','#059669','#d97706'];
|
||||||
|
const LABELS = ['A','B','C','D'];
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.cssText = `
|
||||||
|
display:flex;flex-direction:column;gap:10px;padding:16px;
|
||||||
|
font-family:'Nunito',sans-serif;width:100%;box-sizing:border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Frage
|
||||||
|
const qEl = document.createElement('div');
|
||||||
|
qEl.style.cssText = `
|
||||||
|
background:rgba(255,255,255,0.05);border-radius:12px;padding:16px;
|
||||||
|
color:#fff;font-size:15px;font-weight:700;line-height:1.5;text-align:center;
|
||||||
|
`;
|
||||||
|
qEl.textContent = q.question;
|
||||||
|
container.appendChild(qEl);
|
||||||
|
|
||||||
|
// Antworten
|
||||||
|
let answered = false;
|
||||||
|
answers.forEach((a, i) => {
|
||||||
|
if (!a) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.style.cssText = `
|
||||||
|
background:${COLORS[i] || '#333'}22;border:2px solid ${COLORS[i] || '#333'}88;
|
||||||
|
color:#fff;border-radius:10px;padding:12px 16px;cursor:pointer;
|
||||||
|
font-family:'Nunito',sans-serif;font-size:13px;font-weight:700;
|
||||||
|
text-align:left;transition:all 0.2s;display:flex;gap:10px;align-items:center;
|
||||||
|
`;
|
||||||
|
btn.innerHTML = `<span style="background:${COLORS[i]};border-radius:6px;padding:2px 8px;font-size:12px">${LABELS[i]}</span> ${a}`;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (answered) return;
|
||||||
|
answered = true;
|
||||||
|
const won = (i === correct);
|
||||||
|
// Feedback-Farben
|
||||||
|
answers.forEach((_, j) => {
|
||||||
|
const b = container.querySelectorAll('button')[j];
|
||||||
|
if (!b) return;
|
||||||
|
if (j === correct) {
|
||||||
|
b.style.background = 'rgba(16,185,129,0.3)';
|
||||||
|
b.style.borderColor = '#10b981';
|
||||||
|
} else if (j === i && !won) {
|
||||||
|
b.style.background = 'rgba(239,68,68,0.3)';
|
||||||
|
b.style.borderColor = '#ef4444';
|
||||||
|
}
|
||||||
|
b.style.cursor = 'default';
|
||||||
|
});
|
||||||
|
// Ergebnis-Text
|
||||||
|
const result = document.createElement('div');
|
||||||
|
result.style.cssText = `
|
||||||
|
text-align:center;font-family:'Fredoka One',cursive;font-size:1.2rem;
|
||||||
|
padding:10px;color:${won ? '#10b981' : '#ef4444'};
|
||||||
|
`;
|
||||||
|
result.textContent = won ? '🎉 Richtig!' : `❌ Falsch! Richtig wäre: ${answers[correct]}`;
|
||||||
|
container.appendChild(result);
|
||||||
|
setTimeout(() => onDone(won), 1400);
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(container);
|
||||||
|
return { stop() { wrap.innerHTML = ''; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
144
minigames/reaction.js
Normal file
144
minigames/reaction.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* minigames/reaction.js
|
||||||
|
* ⚡ Reaktionstest — Drück den Knopf so schnell wie möglich!
|
||||||
|
*/
|
||||||
|
window.MG_reaction = (function() {
|
||||||
|
|
||||||
|
const ID = 'reaction';
|
||||||
|
const EMOJI = '⚡';
|
||||||
|
const NAME = 'Reaktionstest';
|
||||||
|
const DESC = 'Wenn der Kreis GRÜN wird — so schnell wie möglich klicken!';
|
||||||
|
const CONTROLS = 'Klick / Leertaste';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#ef4444' };
|
||||||
|
const ROUNDS = 3;
|
||||||
|
const WIN_MS = 600;
|
||||||
|
|
||||||
|
let phase = 'wait';
|
||||||
|
let waitEnd = 0;
|
||||||
|
let reStart = 0;
|
||||||
|
let times = [];
|
||||||
|
let best = Infinity;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer;
|
||||||
|
|
||||||
|
function nextWait() {
|
||||||
|
phase = 'wait';
|
||||||
|
waitEnd = performance.now() + (2 + Math.random() * 3) * 1000;
|
||||||
|
}
|
||||||
|
nextWait();
|
||||||
|
|
||||||
|
const react = () => {
|
||||||
|
if (phase === 'ready') {
|
||||||
|
const t = performance.now() - reStart;
|
||||||
|
times.push(t);
|
||||||
|
best = Math.min(best, t);
|
||||||
|
if (times.length >= ROUNDS) {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
endTimer = setTimeout(() => onDone(best < WIN_MS), 800);
|
||||||
|
phase = 'done';
|
||||||
|
} else {
|
||||||
|
nextWait();
|
||||||
|
}
|
||||||
|
} else if (phase === 'wait') {
|
||||||
|
phase = 'toosoon';
|
||||||
|
setTimeout(nextWait, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); react(); } };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
canvas.addEventListener('click', react);
|
||||||
|
|
||||||
|
function loop(ts) {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
if (phase === 'wait' && ts > waitEnd) {
|
||||||
|
phase = 'ready';
|
||||||
|
reStart = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hintergrund
|
||||||
|
const bg = phase === 'ready' ? '#14532d'
|
||||||
|
: phase === 'toosoon' ? '#7f1d1d'
|
||||||
|
: phase === 'done' ? '#0f0e17'
|
||||||
|
: '#050508';
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const cx = W / 2, cy = H / 2;
|
||||||
|
|
||||||
|
if (phase === 'wait') {
|
||||||
|
// Pulsierender Kreis (rot = warten)
|
||||||
|
const pulse = 0.85 + 0.15 * Math.sin(ts * 0.003);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 60 * pulse, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.2)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#ef444488';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
MGAPI.text(ctx, '⏳', cx, cy - 6, { size: 36 });
|
||||||
|
MGAPI.text(ctx, 'Warte...', cx, cy + 44, { size: 14, color: '#a7a3c2' });
|
||||||
|
MGAPI.text(ctx, `${times.length} / ${ROUNDS} Runden`, cx, H - 20, { size: 12, color: '#64748b' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'ready') {
|
||||||
|
// Leuchtender grüner Kreis
|
||||||
|
ctx.shadowColor = '#22c55e';
|
||||||
|
ctx.shadowBlur = 40;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 65, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(22,163,74,0.4)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#22c55e';
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
MGAPI.text(ctx, '⚡', cx, cy - 8, { size: 44 });
|
||||||
|
MGAPI.text(ctx, 'JETZT!', cx, cy + 48,
|
||||||
|
{ size: 22, family: "'Fredoka One',cursive", color: '#fff', shadow: '#22c55e', shadowBlur: 15 });
|
||||||
|
MGAPI.text(ctx, 'Klick oder Leertaste!', cx, cy + 78, { size: 13, color: 'rgba(255,255,255,0.6)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'toosoon') {
|
||||||
|
MGAPI.text(ctx, '😅', cx, cy - 10, { size: 48 });
|
||||||
|
MGAPI.text(ctx, 'Zu früh!', cx, cy + 46,
|
||||||
|
{ size: 22, family: "'Fredoka One',cursive", color: '#fca5a5' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'done') {
|
||||||
|
MGAPI.resultScreen(ctx, W, H, best < WIN_MS,
|
||||||
|
best < WIN_MS ? `${Math.round(best)} ms — super schnell!` : `${Math.round(best)} ms — noch etwas langsam`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistik
|
||||||
|
if (times.length > 0 && phase !== 'done') {
|
||||||
|
const last = times[times.length - 1];
|
||||||
|
MGAPI.text(ctx, `Letzte: ${Math.round(last)} ms | Beste: ${Math.round(best)} ms`,
|
||||||
|
cx, H - 14, { size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
canvas.removeEventListener('click', react);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
169
minigames/simon.js
Normal file
169
minigames/simon.js
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* minigames/simon.js
|
||||||
|
* 🔴 Simon Says — Merke und wiederhole die Farb-Sequenz!
|
||||||
|
*/
|
||||||
|
window.MG_simon = (function() {
|
||||||
|
|
||||||
|
const ID = 'simon';
|
||||||
|
const EMOJI = '🔴';
|
||||||
|
const NAME = 'Simon Says';
|
||||||
|
const DESC = 'Merke die Farb-Reihenfolge und tippe sie nach!';
|
||||||
|
const CONTROLS = 'Mausklick / Tippen';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
{ id: 0, fill: '#22c55e', glow: 'rgba(34,197,94,0.6)', label: '🟢' },
|
||||||
|
{ id: 1, fill: '#ef4444', glow: 'rgba(239,68,68,0.6)', label: '🔴' },
|
||||||
|
{ id: 2, fill: '#3b82f6', glow: 'rgba(59,130,246,0.6)', label: '🔵' },
|
||||||
|
{ id: 3, fill: '#f59e0b', glow: 'rgba(245,158,11,0.6)', label: '🟡' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const WIN_ROUNDS = cfg.winRounds || 4;
|
||||||
|
|
||||||
|
// Layout: 2×2 Grid
|
||||||
|
const PAD = 16;
|
||||||
|
const GW = (W - PAD * 3) / 2;
|
||||||
|
const GH = (H - PAD * 3 - 32) / 2;
|
||||||
|
const POS = [
|
||||||
|
{ x: PAD, y: 32 + PAD },
|
||||||
|
{ x: PAD*2+GW, y: 32 + PAD },
|
||||||
|
{ x: PAD, y: 32 + PAD*2 + GH },
|
||||||
|
{ x: PAD*2+GW, y: 32 + PAD*2 + GH },
|
||||||
|
];
|
||||||
|
|
||||||
|
let sequence = [];
|
||||||
|
let userSeq = [];
|
||||||
|
let showIdx = -1; // welcher Schritt der Anzeige läuft
|
||||||
|
let phase = 'show'; // 'show' | 'input' | 'result'
|
||||||
|
let litIdx = -1;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, stepTimer, endTimer, result = null;
|
||||||
|
|
||||||
|
function addStep() { sequence.push(Math.floor(Math.random() * 4)); }
|
||||||
|
function startShow() {
|
||||||
|
phase = 'show';
|
||||||
|
litIdx = -1;
|
||||||
|
showIdx = 0;
|
||||||
|
userSeq = [];
|
||||||
|
stepTimer = setInterval(() => {
|
||||||
|
litIdx = (litIdx === -1) ? sequence[showIdx] : -1;
|
||||||
|
if (litIdx === -1) {
|
||||||
|
showIdx++;
|
||||||
|
if (showIdx >= sequence.length) {
|
||||||
|
clearInterval(stepTimer);
|
||||||
|
litIdx = -1;
|
||||||
|
phase = 'input';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInput(colorId) {
|
||||||
|
if (phase !== 'input') return;
|
||||||
|
userSeq.push(colorId);
|
||||||
|
const idx = userSeq.length - 1;
|
||||||
|
|
||||||
|
if (userSeq[idx] !== sequence[idx]) {
|
||||||
|
// Falsch
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
result = 'lose';
|
||||||
|
endTimer = setTimeout(() => onDone(false), 1000);
|
||||||
|
phase = 'result';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userSeq.length === sequence.length) {
|
||||||
|
if (sequence.length >= WIN_ROUNDS) {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
result = 'win';
|
||||||
|
endTimer = setTimeout(() => onDone(true), 900);
|
||||||
|
phase = 'result';
|
||||||
|
} else {
|
||||||
|
// Nächste Runde
|
||||||
|
phase = 'wait';
|
||||||
|
setTimeout(() => {
|
||||||
|
addStep();
|
||||||
|
startShow();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick auf Farb-Button
|
||||||
|
canvas.addEventListener('click', e => {
|
||||||
|
if (phase !== 'input') return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||||
|
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||||
|
POS.forEach((p, i) => {
|
||||||
|
if (mx >= p.x && mx < p.x + GW && my >= p.y && my < p.y + GH)
|
||||||
|
checkInput(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Starten
|
||||||
|
addStep();
|
||||||
|
startShow();
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Felder zeichnen
|
||||||
|
POS.forEach((p, i) => {
|
||||||
|
const col = COLORS[i];
|
||||||
|
const isLit = litIdx === i;
|
||||||
|
const inUser = phase === 'input' && userSeq[userSeq.length - 1] === i;
|
||||||
|
|
||||||
|
ctx.shadowColor = isLit || inUser ? col.glow : 'transparent';
|
||||||
|
ctx.shadowBlur = isLit || inUser ? 30 : 0;
|
||||||
|
|
||||||
|
MGAPI.roundRect(ctx, p.x, p.y, GW, GH, 12,
|
||||||
|
isLit || inUser ? col.fill : `${col.fill}44`,
|
||||||
|
isLit || inUser ? col.fill : `${col.fill}88`);
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Emoji mittig
|
||||||
|
ctx.font = `${Math.min(GW, GH) * 0.4}px serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(col.label, p.x + GW / 2, p.y + GH / 2 + GH * 0.14);
|
||||||
|
});
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
const phaseText = phase === 'show' ? '👀 Schau zu...'
|
||||||
|
: phase === 'input' ? '👆 Deine Runde!'
|
||||||
|
: phase === 'wait' ? '✅ Richtig!'
|
||||||
|
: '';
|
||||||
|
MGAPI.text(ctx, phaseText, W / 2, 18,
|
||||||
|
{ size: 13, color: phase === 'input' ? '#f59e0b' : '#a7a3c2' });
|
||||||
|
MGAPI.text(ctx, `Runde ${sequence.length} / ${WIN_ROUNDS}`, W / 2, H - 10,
|
||||||
|
{ size: 11, color: 'rgba(255,255,255,0.35)' });
|
||||||
|
|
||||||
|
if (result)
|
||||||
|
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||||
|
result === 'win' ? `${sequence.length} Runden gemeistert!` : 'Falsche Farbe!');
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(stepTimer);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
166
minigames/snake.js
Normal file
166
minigames/snake.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* minigames/snake.js
|
||||||
|
* 🐍 Snake — Steuere die Schlange, friss Äpfel ohne gegen die Wand zu fahren
|
||||||
|
*/
|
||||||
|
window.MG_snake = (function() {
|
||||||
|
|
||||||
|
const ID = 'snake';
|
||||||
|
const EMOJI = '🐍';
|
||||||
|
const NAME = 'Snake';
|
||||||
|
const DESC = 'Steuere die Schlange! Friss 3 Äpfel ohne gegen die Wand zu fahren.';
|
||||||
|
const CONTROLS = 'Pfeiltasten oder WASD';
|
||||||
|
const MULTI = 1; // einmalig pro Spiel
|
||||||
|
|
||||||
|
// ── Gemeinsame Spiel-Logik ────────────────────────────────────────────────
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const SZ = 20;
|
||||||
|
const COLS = Math.floor(W / SZ);
|
||||||
|
const ROWS = Math.floor(H / SZ);
|
||||||
|
const WIN_SCORE = cfg.winScore || 3;
|
||||||
|
const theme = cfg.theme || { primary: '#10b981', glow: 'rgba(16,185,129,0.4)' };
|
||||||
|
|
||||||
|
function rndFood() {
|
||||||
|
return {
|
||||||
|
x: Math.floor(Math.random() * COLS),
|
||||||
|
y: Math.floor(Math.random() * ROWS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let snake = [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }];
|
||||||
|
let dir = { x: 1, y: 0 };
|
||||||
|
let nextDir = { x: 1, y: 0 };
|
||||||
|
let food = rndFood();
|
||||||
|
let score = 0;
|
||||||
|
let dead = false;
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer, last = 0;
|
||||||
|
|
||||||
|
// Steuerung
|
||||||
|
function onKey(e) {
|
||||||
|
const map = {
|
||||||
|
ArrowUp: { x: 0, y: -1 }, w: { x: 0, y: -1 },
|
||||||
|
ArrowDown: { x: 0, y: 1 }, s: { x: 0, y: 1 },
|
||||||
|
ArrowLeft: { x: -1, y: 0 }, a: { x: -1, y: 0 },
|
||||||
|
ArrowRight: { x: 1, y: 0 }, d: { x: 1, y: 0 },
|
||||||
|
};
|
||||||
|
const d = map[e.key];
|
||||||
|
if (d && (d.x !== -dir.x || d.y !== -dir.y)) {
|
||||||
|
e.preventDefault();
|
||||||
|
nextDir = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
// Touch-Steuerung (Swipe)
|
||||||
|
let touchStart = null;
|
||||||
|
canvas.addEventListener('touchstart', e => {
|
||||||
|
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}, { passive: true });
|
||||||
|
canvas.addEventListener('touchend', e => {
|
||||||
|
if (!touchStart) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStart.x;
|
||||||
|
const dy = e.changedTouches[0].clientY - touchStart.y;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
nextDir = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 };
|
||||||
|
} else {
|
||||||
|
nextDir = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
function drawGrid() {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let x = 0; x < COLS; x++)
|
||||||
|
for (let y = 0; y < ROWS; y++)
|
||||||
|
ctx.strokeRect(x * SZ, y * SZ, SZ, SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(ts) {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
if (ts - last < 140) return;
|
||||||
|
last = ts;
|
||||||
|
|
||||||
|
dir = nextDir;
|
||||||
|
|
||||||
|
if (!dead) {
|
||||||
|
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
|
||||||
|
if (
|
||||||
|
head.x < 0 || head.x >= COLS ||
|
||||||
|
head.y < 0 || head.y >= ROWS ||
|
||||||
|
snake.some(s => s.x === head.x && s.y === head.y)
|
||||||
|
) {
|
||||||
|
dead = true;
|
||||||
|
if (!endTimer) endTimer = setTimeout(() => onDone(score >= WIN_SCORE), 1200);
|
||||||
|
} else {
|
||||||
|
snake.unshift(head);
|
||||||
|
if (head.x === food.x && head.y === food.y) {
|
||||||
|
score++;
|
||||||
|
food = rndFood();
|
||||||
|
if (score >= WIN_SCORE && !endTimer) {
|
||||||
|
endTimer = setTimeout(() => onDone(true), 1200);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snake.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeichnen
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
drawGrid();
|
||||||
|
|
||||||
|
// Futter
|
||||||
|
ctx.shadowColor = theme.primary;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.font = `${SZ - 2}px serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('🍎', food.x * SZ + SZ / 2, food.y * SZ + SZ / 1.2);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Schlange
|
||||||
|
snake.forEach((s, i) => {
|
||||||
|
ctx.fillStyle = i === 0 ? theme.primary : `${theme.primary}88`;
|
||||||
|
if (i === 0) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 8; }
|
||||||
|
MGAPI.roundRect(ctx, s.x * SZ + 2, s.y * SZ + 2, SZ - 4, SZ - 4, 4, ctx.fillStyle, null);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
MGAPI.text(ctx, `🍎 ${score} / ${WIN_SCORE}`, 8, 14,
|
||||||
|
{ align: 'left', size: 13, color: theme.primary });
|
||||||
|
|
||||||
|
// Ergebnis-Screen
|
||||||
|
if (dead) {
|
||||||
|
MGAPI.resultScreen(ctx, W, H, score >= WIN_SCORE,
|
||||||
|
score >= WIN_SCORE ? `${score} Äpfel gefressen!` : `Nur ${score} Äpfel — nächstes Mal!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vollständiges Spiel (game.html) ──────────────────────────────────────
|
||||||
|
function launch(wrap, W, H, cfg) {
|
||||||
|
return run(wrap, W, H, cfg, won => MGAPI.onResult(won));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor-Vorschau (Test-Popup) ─────────────────────────────────────────
|
||||||
|
function preview(wrap, W, H, cfg) {
|
||||||
|
return run(wrap, W, H, { ...cfg, winScore: 3 }, won => MGAPI.onResult(won));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
202
minigames/spotdiff.js
Normal file
202
minigames/spotdiff.js
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
/**
|
||||||
|
* minigames/spotdiff.js
|
||||||
|
* 🔍 Fehler finden — Finde alle Unterschiede zwischen den zwei Bildern!
|
||||||
|
*/
|
||||||
|
window.MG_spotdiff = (function() {
|
||||||
|
|
||||||
|
const ID = 'spotdiff';
|
||||||
|
const EMOJI = '🔍';
|
||||||
|
const NAME = 'Fehler finden';
|
||||||
|
const DESC = 'Finde alle Unterschiede zwischen den zwei Bildern!';
|
||||||
|
const CONTROLS = 'Mausklick';
|
||||||
|
const MULTI = 1;
|
||||||
|
|
||||||
|
// ── Bild-Sets (canvas-gezeichnete Szenen) ────────────────────────────────
|
||||||
|
// Jede Szene besteht aus drawA(ctx,W,H) und drawB(ctx,W,H),
|
||||||
|
// plus einer Liste von Unterschied-Hotspots { x, y, r } (relativ, 0–1)
|
||||||
|
const SCENES = [
|
||||||
|
{
|
||||||
|
name: 'Bauernhof',
|
||||||
|
drawA(ctx, W, H) {
|
||||||
|
// Himmel
|
||||||
|
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.55);
|
||||||
|
// Gras
|
||||||
|
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
|
||||||
|
// Sonne
|
||||||
|
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.18, H*0.1, 0, Math.PI*2); ctx.fill();
|
||||||
|
// Haus
|
||||||
|
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
|
||||||
|
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
|
||||||
|
// Fenster (2)
|
||||||
|
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
|
||||||
|
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07);
|
||||||
|
// Tür
|
||||||
|
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.39, H*0.46, W*0.05, H*0.12);
|
||||||
|
// Baum (3 Kreise)
|
||||||
|
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.38, W*0.06, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.46, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.44, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
|
||||||
|
// Wolke
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath(); ctx.arc(W*0.55, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.arc(W*0.62, H*0.11, W*0.06, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.arc(W*0.69, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||||
|
},
|
||||||
|
drawB(ctx, W, H) {
|
||||||
|
// Himmel (UNTERSCHIED 1: dunkleres Blau)
|
||||||
|
ctx.fillStyle = '#38bdf8'; ctx.fillRect(0, 0, W, H * 0.55);
|
||||||
|
// Gras
|
||||||
|
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
|
||||||
|
// Sonne (UNTERSCHIED 2: weiter oben)
|
||||||
|
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.10, H*0.1, 0, Math.PI*2); ctx.fill();
|
||||||
|
// Haus
|
||||||
|
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
|
||||||
|
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
|
||||||
|
// Fenster — nur EINES (UNTERSCHIED 3)
|
||||||
|
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
|
||||||
|
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07); // rotes Fenster
|
||||||
|
// Tür
|
||||||
|
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.39, H*0.46, W*0.05, H*0.12);
|
||||||
|
// Baum (kleiner — UNTERSCHIED 4)
|
||||||
|
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.42, W*0.04, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.48, W*0.035, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.46, W*0.035, 0, Math.PI*2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
|
||||||
|
// Wolke fehlt (UNTERSCHIED 5)
|
||||||
|
},
|
||||||
|
// Hotspots in Bild B (relativ 0-1)
|
||||||
|
spots: [
|
||||||
|
{ x: 0.42, y: 0.12, r: 0.07, label: 'Himmelfarbe' },
|
||||||
|
{ x: 0.15, y: 0.10, r: 0.08, label: 'Sonne' },
|
||||||
|
{ x: 0.48, y: 0.41, r: 0.06, label: 'Rotes Fenster' },
|
||||||
|
{ x: 0.75, y: 0.44, r: 0.07, label: 'Kleinerer Baum' },
|
||||||
|
{ x: 0.62, y: 0.13, r: 0.08, label: 'Fehlende Wolke' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const scene = SCENES[0];
|
||||||
|
const theme = cfg.theme || { primary: '#84cc16' };
|
||||||
|
|
||||||
|
// Zwei Canvas nebeneinander
|
||||||
|
const halfW = Math.floor((W - 8) / 2);
|
||||||
|
const imgH = H - 64;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.cssText = 'position:relative;width:100%;';
|
||||||
|
wrap.appendChild(container);
|
||||||
|
|
||||||
|
const canvasA = document.createElement('canvas');
|
||||||
|
canvasA.width = halfW; canvasA.height = imgH;
|
||||||
|
canvasA.style.cssText = `display:inline-block;border-radius:8px;cursor:default;`;
|
||||||
|
const canvasB = document.createElement('canvas');
|
||||||
|
canvasB.width = halfW; canvasB.height = imgH;
|
||||||
|
canvasB.style.cssText = `display:inline-block;border-radius:8px;cursor:crosshair;margin-left:8px;`;
|
||||||
|
|
||||||
|
container.appendChild(canvasA);
|
||||||
|
container.appendChild(canvasB);
|
||||||
|
|
||||||
|
// HUD-Canvas
|
||||||
|
const hud = document.createElement('canvas');
|
||||||
|
hud.width = W; hud.height = 48;
|
||||||
|
hud.style.display = 'block';
|
||||||
|
container.appendChild(hud);
|
||||||
|
|
||||||
|
const ctxA = canvasA.getContext('2d');
|
||||||
|
const ctxB = canvasB.getContext('2d');
|
||||||
|
const ctxH = hud.getContext('2d');
|
||||||
|
|
||||||
|
scene.drawA(ctxA, halfW, imgH);
|
||||||
|
scene.drawB(ctxB, halfW, imgH);
|
||||||
|
|
||||||
|
let found = new Set();
|
||||||
|
let marks = []; // { x, y, ok }
|
||||||
|
let stopped = false;
|
||||||
|
let endTimer;
|
||||||
|
|
||||||
|
function drawMarks() {
|
||||||
|
scene.drawB(ctxB, halfW, imgH); // neu zeichnen
|
||||||
|
marks.forEach(m => {
|
||||||
|
ctxB.strokeStyle = m.ok ? '#22c55e' : '#ef4444';
|
||||||
|
ctxB.lineWidth = 3;
|
||||||
|
ctxB.beginPath();
|
||||||
|
ctxB.arc(m.x, m.y, 18, 0, Math.PI * 2);
|
||||||
|
ctxB.stroke();
|
||||||
|
if (m.ok) {
|
||||||
|
ctxB.fillStyle = 'rgba(34,197,94,0.25)';
|
||||||
|
ctxB.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHUD() {
|
||||||
|
ctxH.clearRect(0, 0, W, 48);
|
||||||
|
MGAPI.text(ctxH, `🔍 ${found.size} / ${scene.spots.length} Unterschiede gefunden`, W/2, 16,
|
||||||
|
{ size: 13, color: theme.primary });
|
||||||
|
MGAPI.text(ctxH, 'Klicke auf die Unterschiede im rechten Bild', W/2, 36,
|
||||||
|
{ size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasB.addEventListener('click', e => {
|
||||||
|
if (found.size >= scene.spots.length) return;
|
||||||
|
const rect = canvasB.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) * (canvasB.width / rect.width);
|
||||||
|
const my = (e.clientY - rect.top) * (canvasB.height / rect.height);
|
||||||
|
|
||||||
|
// Nächsten Spot suchen
|
||||||
|
let hit = null;
|
||||||
|
scene.spots.forEach((s, i) => {
|
||||||
|
if (found.has(i)) return;
|
||||||
|
const sx = s.x * halfW, sy = s.y * imgH;
|
||||||
|
const d = Math.sqrt((mx - sx) ** 2 + (my - sy) ** 2);
|
||||||
|
if (d < s.r * Math.min(halfW, imgH)) hit = i;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hit !== null) {
|
||||||
|
found.add(hit);
|
||||||
|
const sx = scene.spots[hit].x * halfW;
|
||||||
|
const sy = scene.spots[hit].y * imgH;
|
||||||
|
marks.push({ x: sx, y: sy, ok: true });
|
||||||
|
// Auch in Bild A markieren
|
||||||
|
ctxA.strokeStyle = '#22c55e';
|
||||||
|
ctxA.lineWidth = 3;
|
||||||
|
ctxA.beginPath(); ctxA.arc(sx, sy, 18, 0, Math.PI * 2); ctxA.stroke();
|
||||||
|
ctxA.fillStyle = 'rgba(34,197,94,0.2)'; ctxA.fill();
|
||||||
|
} else {
|
||||||
|
marks.push({ x: mx, y: my, ok: false });
|
||||||
|
setTimeout(() => {
|
||||||
|
marks = marks.filter(m => m.ok);
|
||||||
|
drawMarks(); drawHUD();
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMarks();
|
||||||
|
drawHUD();
|
||||||
|
|
||||||
|
if (found.size >= scene.spots.length && !endTimer) {
|
||||||
|
endTimer = setTimeout(() => onDone(true), 900);
|
||||||
|
// Overlay
|
||||||
|
ctxH.fillStyle = 'rgba(16,185,129,0.9)';
|
||||||
|
MGAPI.roundRect(ctxH, W/2-120, 4, 240, 38, 8, 'rgba(16,185,129,0.9)', null);
|
||||||
|
MGAPI.text(ctxH, '🎉 Alle Unterschiede gefunden!', W/2, 24,
|
||||||
|
{ size: 14, family: "'Fredoka One',cursive", color: '#fff' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
drawHUD();
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
170
minigames/typing.js
Normal file
170
minigames/typing.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* minigames/typing.js
|
||||||
|
* ⌨️ Tipp-Rennen — Tippe den Text so schnell wie möglich!
|
||||||
|
*/
|
||||||
|
window.MG_typing = (function() {
|
||||||
|
|
||||||
|
const ID = 'typing';
|
||||||
|
const EMOJI = '⌨️';
|
||||||
|
const NAME = 'Tipp-Rennen';
|
||||||
|
const DESC = 'Tippe das angezeigte Wort so schnell wie möglich!';
|
||||||
|
const CONTROLS = 'Tastatur';
|
||||||
|
const MULTI = 3;
|
||||||
|
|
||||||
|
const WORD_POOLS = {
|
||||||
|
easy: ['Katze','Hund','Baum','Haus','Ball','Buch','Schule','Spiel','Kind','Mond'],
|
||||||
|
medium: ['Abenteuer','Computer','Programm','Zauber','Kristall','Phantom','Roboter','Galaxie'],
|
||||||
|
hard: ['Dinosaurier','Wissenschaft','Programmierung','Abenteuerland','Weltentdecker'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(wrap, W, H, cfg, onDone) {
|
||||||
|
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||||
|
const theme = cfg.theme || { primary: '#f43f5e' };
|
||||||
|
const WIN_WPM = cfg.winWpm || 30; // mind. 30 WPM zum Gewinnen
|
||||||
|
const ROUNDS = 3;
|
||||||
|
|
||||||
|
const allWords = [...WORD_POOLS.easy, ...WORD_POOLS.medium];
|
||||||
|
let words = [];
|
||||||
|
for (let i = 0; i < ROUNDS; i++) {
|
||||||
|
const pool = i < 2 ? WORD_POOLS.easy : WORD_POOLS.medium;
|
||||||
|
words.push(pool[Math.floor(Math.random() * pool.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let round = 0;
|
||||||
|
let typed = '';
|
||||||
|
let startTs = null;
|
||||||
|
let times = [];
|
||||||
|
let stopped = false;
|
||||||
|
let raf, endTimer, result = null;
|
||||||
|
|
||||||
|
// HTML-Input über dem Canvas
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.autocorrect = 'off';
|
||||||
|
input.autocapitalize = 'none';
|
||||||
|
input.spellcheck = false;
|
||||||
|
input.style.cssText = `
|
||||||
|
position:absolute;left:-9999px;top:0;opacity:0;width:1px;height:1px;
|
||||||
|
`;
|
||||||
|
wrap.style.position = 'relative';
|
||||||
|
wrap.appendChild(input);
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
canvas.addEventListener('click', () => input.focus());
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
typed = input.value;
|
||||||
|
if (!startTs && typed.length > 0) startTs = performance.now();
|
||||||
|
|
||||||
|
const word = words[round];
|
||||||
|
if (typed.toLowerCase() === word.toLowerCase()) {
|
||||||
|
const elapsed = (performance.now() - startTs) / 1000 / 60; // Minuten
|
||||||
|
const wpm = Math.round(word.length / 5 / elapsed); // Standard: 5 Zeichen = 1 Wort
|
||||||
|
times.push({ word, wpm });
|
||||||
|
typed = '';
|
||||||
|
input.value = '';
|
||||||
|
startTs = null;
|
||||||
|
|
||||||
|
round++;
|
||||||
|
if (round >= ROUNDS) {
|
||||||
|
const avgWpm = Math.round(times.reduce((a, b) => a + b.wpm, 0) / times.length);
|
||||||
|
result = avgWpm >= WIN_WPM ? 'win' : 'lose';
|
||||||
|
endTimer = setTimeout(() => onDone(result === 'win'), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function loop(ts) {
|
||||||
|
if (stopped) return;
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#050508';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
if (round >= ROUNDS) {
|
||||||
|
const avgWpm = times.length
|
||||||
|
? Math.round(times.reduce((a,b) => a+b.wpm, 0) / times.length)
|
||||||
|
: 0;
|
||||||
|
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||||
|
result === 'win' ? `${avgWpm} WPM — richtig schnell!` : `${avgWpm} WPM — weiter üben!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = words[round];
|
||||||
|
const elapsed = startTs ? (ts - startTs) / 1000 : 0;
|
||||||
|
|
||||||
|
// Fortschrittsbalken (Timing-Druck)
|
||||||
|
const timeLimit = 10;
|
||||||
|
const prog = Math.min(1, elapsed / timeLimit);
|
||||||
|
if (prog >= 1 && !endTimer && !result) {
|
||||||
|
result = 'lose';
|
||||||
|
endTimer = setTimeout(() => onDone(false), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
const barW = W - 40;
|
||||||
|
MGAPI.roundRect(ctx, 20, H - 28, barW, 10, 4, 'rgba(255,255,255,0.07)', null);
|
||||||
|
const barColor = prog < 0.6 ? theme.primary : prog < 0.8 ? '#f59e0b' : '#ef4444';
|
||||||
|
MGAPI.roundRect(ctx, 20, H - 28, barW * (1 - prog), 10, 4, barColor, null);
|
||||||
|
|
||||||
|
// Rundenanzeige
|
||||||
|
MGAPI.text(ctx, `⌨️ Runde ${round + 1} / ${ROUNDS}`, W / 2, 20, { size: 12, color: theme.primary });
|
||||||
|
|
||||||
|
// Zu tippendes Wort
|
||||||
|
MGAPI.roundRect(ctx, W/2-140, H*0.2, 280, 60, 12,
|
||||||
|
'rgba(255,255,255,0.05)', `${theme.primary}44`);
|
||||||
|
MGAPI.text(ctx, word, W / 2, H * 0.2 + 34,
|
||||||
|
{ size: 28, family: "'Fredoka One',cursive", color: '#fff' });
|
||||||
|
|
||||||
|
// Eingabe-Anzeige (zeichenweiser Vergleich)
|
||||||
|
const charW = 26;
|
||||||
|
const startX = W / 2 - (word.length * charW) / 2;
|
||||||
|
const charY = H * 0.55;
|
||||||
|
MGAPI.text(ctx, 'Tippe:', W / 2, charY - 24, { size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||||
|
|
||||||
|
for (let i = 0; i < word.length; i++) {
|
||||||
|
const cx = startX + i * charW + charW / 2;
|
||||||
|
const tc = (typed[i] || '').toLowerCase();
|
||||||
|
const wc = word[i].toLowerCase();
|
||||||
|
let color;
|
||||||
|
if (!typed[i]) color = 'rgba(255,255,255,0.2)';
|
||||||
|
else if (tc === wc) color = '#22c55e';
|
||||||
|
else color = '#ef4444';
|
||||||
|
|
||||||
|
MGAPI.roundRect(ctx, startX + i * charW, charY - 4, charW - 2, 32, 4,
|
||||||
|
'rgba(255,255,255,0.04)', `${color}66`);
|
||||||
|
MGAPI.text(ctx, typed[i] || word[i], cx, charY + 12, { size: 18, color });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor-Blinken
|
||||||
|
if (startTs || typed.length === 0) {
|
||||||
|
const cx = startX + Math.min(typed.length, word.length) * charW + 2;
|
||||||
|
if (Math.floor(ts / 500) % 2 === 0) {
|
||||||
|
ctx.fillStyle = theme.primary;
|
||||||
|
ctx.fillRect(cx, charY, 3, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// letzten WPM anzeigen
|
||||||
|
if (times.length > 0) {
|
||||||
|
const last = times[times.length - 1];
|
||||||
|
MGAPI.text(ctx, `Letztes: ${last.wpm} WPM`, W / 2, H - 42, { size: 11, color: 'rgba(255,255,255,0.35)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
if (input.parentNode) input.parentNode.removeChild(input);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||||
|
|
||||||
|
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue