feat: initial project structure with modular mini-game system

- editor.html: 5-step game editor with live Python code split-screen
- game.html: playable board game engine
- codegen.js: Python live-code generator (extracted from editor)
- logo.png: PH Weingarten logo (extracted from Base64)
- minigames/_api.js: shared Mini-Game API (makeCanvas, onResult, helpers)
- 12 Mini-Games as individual modules (launch + preview API):
  snake, flappy, memory, quiz, reaction,
  basketball, catch, maze, simon, typing, puzzle, spotdiff
- README.md with architecture docs and deployment guide

Refactoring highlights:
  - editor.html: 251 KB → 102 KB (-59%)
  - Mini-games fully decoupled, each ~100-200 lines
  - All 12 games now have working launch() + preview()
  - Maze uses recursive backtracker algorithm
  - spotdiff uses canvas-drawn scene with 5 differences
This commit is contained in:
Spiel-Generator Workshop 2026-03-14 22:12:25 +00:00
commit 016be6ea90
19 changed files with 5261 additions and 0 deletions

4
.gitignore vendored Normal file
View file

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

184
README.md Normal file
View file

@ -0,0 +1,184 @@
# 🎲 Spiel-Generator
### 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.
---
## Projektstruktur
```
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 implementiert
**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
**Spiel (`game.html`)**
- Animiertes Canvas-Spielfeld, themenabhängige Farben/Partikel
- Würfelanimation, Story-Overlays, HUD
- Win/GameOver-Screen mit Statistiken
### 🎮 Mini-Games
| 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) | ✅ |
### 🔧 Offene Aufgaben
| Aufgabe | Priorität |
|---------|-----------|
| **Veröffentlichungs-System**: Config als Base64 in URL, QR-Code generieren | 🔴 Hoch |
| **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 |
---
## 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.
---
## Cache leeren (Entwicklung)
```bash
# Browser-Konsole
localStorage.removeItem('gameConfig'); location.reload();
```
Vivaldi/Chrome: `Ctrl+Shift+R` (Hard Reload)
---
## Dateigrößen
| 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** |
---
*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);

1664
editor.html Normal file

File diff suppressed because it is too large Load diff

1106
game.html Normal file

File diff suppressed because it is too large Load diff

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 };
})();