Compare commits

...

No commits in common. "e7d4b720bc3845fc4d1ec5410426a24ab9483a6a" and "2db4a7cb79b4009cbfb30baa17251d782789b188" have entirely different histories.

19 changed files with 4827 additions and 181 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.DS_Store
Thumbs.db
*.swp
node_modules/

322
README.md
View file

@ -1,181 +1,207 @@
# Spiel-Generator
# 🎲 Spiel-Generator
### PH Weingarten · Medien- und Bildungsmanagement & Informatik (Lehramt)
---
## 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 |
> 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.
---
## Projektstruktur & Architektur
## Projektstruktur
- **Technologie:** Reines HTML + CSS + JavaScript (keine Abhängigkeiten, kein Build-Prozess)
- **Hosting:** Gedacht für GitHub Pages
- **Datenaustausch:** Spielkonfiguration wird via `localStorage.gameConfig` gespeichert; `game.html` liest daraus beim Start
- **Keine Serverpflicht:** Alles läuft clientseitig
```
edu-boardgame-generator/
├── editor.html # 5-Schritte-Editor-Wizard
├── 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
### 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
- **Schritt 2 Gestaltung:** 12 Spielfiguren, 12 Hintergründe/Welten, Live-Kombi-Vorschau
- **Schritt 3 Spielfeld & Regeln:**
- Würfeln vs. Einzel-Schritt
- Leben-System vs. Punkte-System
- Feldanzahl (620) 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
**Spiel (`game.html`)**
- Animiertes Canvas-Spielfeld, themenabhängige Farben/Partikel
- Würfelanimation, Story-Overlays, HUD
- Win/GameOver-Screen mit Statistiken
#### Python-Live-Code (Split-Screen rechts)
### 🎮 Mini-Games
- Progressiver Aufbau: Code erscheint **Schritt für Schritt** wenn Felder angeklickt/befüllt werden
- Beginn: Leerer Code mit Hinweiskommentar
- Fokus-getriggert: Jedes Feld/Button enthüllt genau den zugehörigen Codeabschnitt
- **Korrekte Python-Syntax** mit echtem Syntaxhighlighting (Keywords, Strings, Funktionen, Kommentare)
- Dynamischer Dateiname: `spielname_von_entwicklername.py`
- Resizer-Balken: Split-Breite per Maus verschiebbar
- Zeilennummern, Statusanzeige (`● Live` / `● Bereit`)
- Dateiname-Header mit macOS-Stoplight-Dots
| Game | Status Editor-Vorschau | Status Vollspiel |
|------|----------------------|-----------------|
| 🐍 Snake | ✅ | ✅ |
| 🐦 Flappy Bird | ✅ | ✅ |
| 🃏 Memory | ✅ | ✅ |
| ❓ Quiz | ✅ | ✅ |
| ⚡ Reaktionstest | ✅ | ✅ |
| 🏀 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
- Sticky Progress-Bar (volle Breite) mit 5 Schritt-Dots
- Zusammenfassungs-Banner beim Vorwärtsnavigieren
- `localStorage`-Persistenz (Zustand bleibt nach Reload)
- **↺ Reset-Button** zum sauberen Neustart (löscht localStorage)
- Responsiv: Code-Panel blendet sich unter 900px aus
- Scrollbar-Styling einheitlich (lila, schlank)
| Aufgabe | Priorität |
|---------|-----------|
| **Veröffentlichungs-System**: ✅ Implementiert (URL-Hash, QR-Code, Share-Buttons) | ✅ Fertig |
| **Mehr Szenen für Fehler finden** (spotdiff): 35 verschiedene Bilder | 🟡 Mittel |
| **Schwierigkeitsgrade** in Mini-Games (leicht/mittel/schwer) | 🟡 Mittel |
| **Touch/Mobile** Drag&Drop im Editor | 🟡 Mittel |
| **Highscore-System** optional | 🟢 Niedrig |
| **Mehrsprachigkeit** | 🟢 Niedrig |
---
#### Spiel (`game.html`)
## URL-Sharing System
- Vollständiges animiertes Spielfeld mit Canvas-Rendering
- Thema-abhängige Farben & Partikel (12 Hintergründe/Themen)
- Spielfigur mit Bounce-Effekt und Glow
- Farbkodierte Felder pro Mini-Game-Typ
- Besuchter-Pfad leuchtet auf
- HUD: Feld-Position, Leben oder Punkte
- Würfel-Animation
- Story-Overlay (Erzähltexte)
- Mini-Game-Overlay mit Beschreibung und Steuerungshinweis
- Win-Screen und Game-Over-Screen mit Statistiken
Der "🚀 Veröffentlichen"-Button im Editor erzeugt einen **selbsttragenden Link**:
```
https://deinserver.de/game.html#z:H4sIAAAAAAAAA6tWKkktLlGyUlIqS...
```
**Technisch:**
- Config → `JSON.stringify``pako.deflate` (gzip) → `Base64``#URL-Hash`
- Typische Länge: ~8001100 Zeichen ✅ QR-Code-tauglich (< 4000 Zeichen)
- `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 |
|-----------|--------|
| 🐍 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) |
Vivaldi/Chrome: `Ctrl+Shift+R` (Hard Reload)
---
## 🔧 Bekannte offene Aufgaben
## Dateigrößen
### Hoch-Priorität
| Aufgabe | Beschreibung |
|---------|--------------|
| 🎮 **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 |
| 🔗 **URL-System / Veröffentlichung** | `doPublish()` zeigt nur Alert. Ziel: Config als Base64 in URL kodieren, QR-Code generieren, direkter GitHub-Pages-Link |
### Mittel-Priorität
| 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 |
| Datei | Größe |
|-------|-------|
| `editor.html` | ~102 KB |
| `game.html` | ~43 KB |
| `codegen.js` | ~14 KB |
| `logo.png` | ~89 KB |
| `minigames/` (13 Dateien) | ~77 KB |
| **Gesamt** | **~325 KB** |
---
## Workflow-Empfehlungen
- 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 | ~35 Tage |
| URL/QR-Code Veröffentlichungs-System | ~12 Tage |
| Mobile Touch-Drag | ~1 Tag |
| GitHub Pages + Deployment | ~24 Stunden |
| Testing & Bugfixing für Workshop | ~12 Tage |
| **Gesamt** | **~610 Tage** |
---
*Stand: März 2026 · Entwicklung im Workshop mit Claude (Anthropic)*
*Entwickelt im Workshop mit Claude (Anthropic) · Stand März 2026*

311
codegen.js Normal file
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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);

File diff suppressed because it is too large Load diff

View file

@ -360,6 +360,7 @@ canvas#bgCanvas{
<div id="transOverlay"></div>
<div id="toast"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
<script>
/* roundRect polyfill for older browsers */
if(!CanvasRenderingContext2D.prototype.roundRect){
@ -374,7 +375,39 @@ if(!CanvasRenderingContext2D.prototype.roundRect){
}
/* ══════════ 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 fieldCount = cfg.fieldCount || fields.length || 10;
const rules = cfg.rules || {movement:'dice',fail:'lives',lives:'3',pts:'10'};

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

93
minigames/_api.js Normal file
View 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
View 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 (0100), 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
View 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
View 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
View 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
View 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 01)
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
View 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 (18)!';
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
View 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
View 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
View 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
View 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
View 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, 01)
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
View 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 };
})();