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:
commit
016be6ea90
19 changed files with 5261 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
node_modules/
|
||||
184
README.md
Normal file
184
README.md
Normal 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): 3–5 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
311
codegen.js
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* codegen.js
|
||||
* Python Live-Code-Generator für den Split-Screen
|
||||
* Wird von editor.html geladen
|
||||
*/
|
||||
|
||||
// ── Progressive unlock tracking ─────────────────────────────
|
||||
const unlocked = new Set();
|
||||
let lastKey = null; // which section was LAST interacted with → gets yellow highlight
|
||||
|
||||
function setCodeFocus(key){
|
||||
unlocked.add(key);
|
||||
lastKey = key;
|
||||
scheduleCodeUpdate(50);
|
||||
}
|
||||
|
||||
// Focus listener for text inputs
|
||||
document.addEventListener('focusin', e=>{
|
||||
const id = e.target.id || '';
|
||||
if(id==='s1devname') setCodeFocus('dev');
|
||||
if(id==='s1name') setCodeFocus('gamename');
|
||||
if(id==='s1desc') setCodeFocus('gamedesc');
|
||||
if(id && id.startsWith('qq')) setCodeFocus('quiz');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
function buildPythonCode(){
|
||||
const s = ST;
|
||||
const fig = FIGURES.find(f=>f.id===s.figure);
|
||||
const bg = BACKGROUNDS.find(b=>b.id===s.background);
|
||||
|
||||
// Empty until first interaction
|
||||
if(unlocked.size===0 && !s.devName && !s.name && !s.figure) return [];
|
||||
|
||||
const lines = [];
|
||||
// hi() returns true only for the last-touched key
|
||||
const hi = key => lastKey === key;
|
||||
|
||||
// Header + imports — always shown once anything unlocked
|
||||
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
|
||||
lines.push({t:'cm', v:'# boardgame.py — Spiel-Generator PH Weingarten'});
|
||||
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'cm', v:'# Module importieren'});
|
||||
lines.push({t:'kw', v:'import', rest:' boardgame_engine as engine'});
|
||||
lines.push({t:'kw', v:'import', rest:' minigames'});
|
||||
lines.push({t:'kw', v:'from', rest:' config import GameConfig, Field, StoryText'});
|
||||
lines.push({t:'bl'});
|
||||
|
||||
// DEVELOPER
|
||||
if(unlocked.has('dev') || s.devName){
|
||||
lines.push({t:'cm', v:'# Wer hat dieses Spiel programmiert?'});
|
||||
if(s.devName)
|
||||
lines.push({t:'ass', var:'developer_name', val:`"${s.devName}"`, hi:hi('dev')});
|
||||
else
|
||||
lines.push({t:'ph', var:'developer_name', hint:'← tippe deinen Namen'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// GAME NAME
|
||||
if(unlocked.has('gamename') || s.name){
|
||||
lines.push({t:'cm', v:'# Wie heißt das Spiel?'});
|
||||
if(s.name)
|
||||
lines.push({t:'ass', var:'game_name', val:`"${s.name}"`, hi:hi('gamename')});
|
||||
else
|
||||
lines.push({t:'ph', var:'game_name', hint:'← tippe den Spielnamen'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// DESCRIPTION
|
||||
if(unlocked.has('gamedesc') || s.desc){
|
||||
lines.push({t:'cm', v:'# Kurze Beschreibung des Spiels'});
|
||||
if(s.desc)
|
||||
lines.push({t:'ass', var:'game_description', val:`"${s.desc}"`, hi:hi('gamedesc')});
|
||||
else
|
||||
lines.push({t:'ph', var:'game_description', hint:'← tippe eine Beschreibung'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// FIGURE
|
||||
if(unlocked.has('figure') || s.figure){
|
||||
lines.push({t:'cm', v:'# Spielfigur auswählen'});
|
||||
if(s.figure)
|
||||
lines.push({t:'ass', var:'player_figure',
|
||||
val:`"${s.figure}" # ${fig?fig.e+' '+fig.n:''}`, hi:hi('figure')});
|
||||
else
|
||||
lines.push({t:'ph', var:'player_figure', hint:'← klicke eine Figur an'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// BACKGROUND
|
||||
if(unlocked.has('background') || s.background){
|
||||
lines.push({t:'cm', v:'# Spielwelt / Hintergrund'});
|
||||
if(s.background)
|
||||
lines.push({t:'ass', var:'world_setting',
|
||||
val:`"${s.background}" # ${bg?bg.e+' '+bg.n:''}`, hi:hi('background')});
|
||||
else
|
||||
lines.push({t:'ph', var:'world_setting', hint:'← klicke eine Welt an'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// MOVEMENT
|
||||
if(unlocked.has('movement')){
|
||||
lines.push({t:'cm', v:'# Wie viele Felder pro Runde?'});
|
||||
lines.push({t:'ass', var:'movement_type',
|
||||
val: s.rules.movement==='step' ? '"schritt" # immer genau 1 Feld'
|
||||
: '"wuerfeln" # zufällig 1-6 Felder',
|
||||
hi:hi('movement')});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// FAIL MODE
|
||||
if(unlocked.has('failmode')){
|
||||
lines.push({t:'cm', v:'# Was passiert wenn man verliert?'});
|
||||
if(s.rules.fail==='lives'){
|
||||
lines.push({t:'ass', var:'verlust_system', val:'"leben"', hi:hi('failmode')});
|
||||
lines.push({t:'ass', var:'anzahl_leben', val:String(s.rules.lives||3), hi:hi('lives')});
|
||||
} else {
|
||||
lines.push({t:'ass', var:'verlust_system', val:'"punkte"', hi:hi('failmode')});
|
||||
lines.push({t:'ass', var:'punkte_pro_sieg',val:String(s.rules.pts||10), hi:hi('pts')});
|
||||
}
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// LIVES / PTS sub-option
|
||||
if(unlocked.has('lives') && s.rules.fail==='lives'){
|
||||
// already rendered above, just make sure hi updates
|
||||
}
|
||||
|
||||
// FIELD COUNT
|
||||
if(unlocked.has('fieldcount')){
|
||||
lines.push({t:'cm', v:'# Wie groß ist das Spielfeld?'});
|
||||
lines.push({t:'ass', var:'anzahl_felder', val:String(s.fieldCount), hi:hi('fieldcount')});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// BOARD SETUP — only once a game was dropped
|
||||
const MG_PY ={snake:'schlangen_spiel',flappy:'flug_spiel',memory:'memory',quiz:'quiz',reaction:'reaktionstest',basketball:'basketball',catch:'fangen',maze:'labyrinth',simon:'simon_says',puzzle:'raetsel',spotdiff:'unterschiede',typing:'tipp_rennen'};
|
||||
const MG_CM ={snake:'Schlange steuern',flappy:'Durch Hindernisse fliegen',memory:'Paare finden',quiz:'Frage beantworten',reaction:'Schnell reagieren',basketball:'Ball werfen',catch:'Objekte fangen',maze:'Ausweg finden',simon:'Sequenz merken',puzzle:'Rätsel lösen',spotdiff:'Unterschiede finden',typing:'Text eintippen'};
|
||||
|
||||
const hasAnyField = (s.fields||[]).some(Boolean);
|
||||
if(unlocked.has('fields') || hasAnyField){
|
||||
lines.push({t:'cm', v:'# Spielfeld aufbauen — jedes Feld wird definiert'});
|
||||
lines.push({t:'fn-def', name:'setup_spielfeld', args:''});
|
||||
lines.push({t:'ind', v:'felder = []'});
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'ind-cm', v:'# Startfeld (Index 0)'});
|
||||
lines.push({t:'ind', v:'felder.append(Field(index=0, typ="start"))'});
|
||||
|
||||
for(let i=1;i<s.fieldCount-1;i++){
|
||||
const fId=(s.fields||[])[i];
|
||||
lines.push({t:'bl'});
|
||||
if(fId){
|
||||
lines.push({t:'ind-cm', v:`# Feld ${i}: Mini-Game → ${MG_CM[fId]||fId}`});
|
||||
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="minigame", spiel="${MG_PY[fId]||fId}"))`,
|
||||
hi: hi('fields') && (s.fields||[])[i]===fId});
|
||||
} else {
|
||||
lines.push({t:'ind-cm', v:`# Feld ${i}: kein Spiel, einfach durchlaufen`});
|
||||
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="leer"))`});
|
||||
}
|
||||
}
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'ind-cm', v:`# Zielfeld (Index ${s.fieldCount-1}) 🏁`});
|
||||
lines.push({t:'ind', v:`felder.append(Field(index=${s.fieldCount-1}, typ="ziel"))`});
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'ind', v:'return felder'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// MINIGAME FUNCTIONS
|
||||
const usedGames=[...new Set((s.fields||[]).filter(Boolean))];
|
||||
if(usedGames.length>0){
|
||||
const MG_CM2={snake:'Schlange steuern ohne Wand zu treffen',flappy:'Durch alle Hindernisse fliegen',memory:'Alle Kartenpaare finden',quiz:'Die richtige Antwort wählen',reaction:'Schnell auf Signal drücken',basketball:'Ball zum richtigen Moment werfen',catch:'Fallende Objekte auffangen',maze:'Den Ausgang aus dem Labyrinth finden',simon:'Farbreihenfolge merken und wiederholen',puzzle:'Zahlen in die richtige Reihenfolge bringen',spotdiff:'Alle Unterschiede im Bild finden',typing:'Text so schnell wie möglich eintippen'};
|
||||
lines.push({t:'cm', v:'# Für jedes Mini-Game gibt es eine eigene Funktion'});
|
||||
usedGames.forEach(id=>{
|
||||
const pyName=MG_PY[id]||id;
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'fn-def', name:`spiele_${pyName}`, args:'spieler'});
|
||||
lines.push({t:'ind-cm', v:`# Aufgabe: ${MG_CM2[id]||id}`});
|
||||
lines.push({t:'ind', v:`ergebnis = minigames.${pyName}.starten(spieler)`});
|
||||
lines.push({t:'ind', v:`return ergebnis.gewonnen`});
|
||||
});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// QUIZ
|
||||
const quizItems=(s.quizData||[]).filter(q=>q&&q.question);
|
||||
if(quizItems.length>0 || unlocked.has('quiz')){
|
||||
lines.push({t:'cm', v:'# Quiz-Fragen für das Spiel'});
|
||||
lines.push({t:'ass', var:'fragen', val:'[', hi:false});
|
||||
quizItems.forEach(q=>{
|
||||
const clean=q.question.replace(/"/g,"'").slice(0,50);
|
||||
const ans=q.answers.map(a=>`"${(a||'').replace(/"/g,"'")}"`).join(', ');
|
||||
const cor=q.correct!=null?`"${(q.answers[q.correct]||'').replace(/"/g,"'")}"`:'None';
|
||||
lines.push({t:'ind', v:`{"frage": "${clean}", "antworten": [${ans}], "richtig": ${cor}},`});
|
||||
});
|
||||
if(quizItems.length===0) lines.push({t:'ind-cm', v:'# noch keine Fragen eingetragen...'});
|
||||
lines.push({t:'ass-end', v:']'});
|
||||
lines.push({t:'bl'});
|
||||
}
|
||||
|
||||
// MAIN BLOCK
|
||||
lines.push({t:'cm', v:'# ── Spiel starten ───────────────────────────'});
|
||||
lines.push({t:'kw', v:'if', rest:" __name__ == '__main__':"});
|
||||
lines.push({t:'ind-cm', v:'# Konfiguration zusammenstellen und Spiel starten'});
|
||||
lines.push({t:'ind', v:'config = GameConfig('});
|
||||
lines.push({t:'ind2', v:`name=${s.name?`"${s.name}"`:'""'},`});
|
||||
lines.push({t:'ind2', v:`developer=${s.devName?`"${s.devName}"`:'""'},`});
|
||||
if(s.figure) lines.push({t:'ind2', v:`figure="${s.figure}",`});
|
||||
if(s.background) lines.push({t:'ind2', v:`setting="${s.background}",`});
|
||||
if(hasAnyField) lines.push({t:'ind2', v:'board=setup_spielfeld(),'});
|
||||
if(quizItems.length) lines.push({t:'ind2', v:'questions=fragen,'});
|
||||
lines.push({t:'ind', v:')'});
|
||||
lines.push({t:'bl'});
|
||||
lines.push({t:'ind', v:'engine.run(config)'});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderToken(line){
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
let r='';
|
||||
switch(line.t){
|
||||
case 'cm': r=`<span class="py-cm">${esc(line.v)}</span>`; break;
|
||||
case 'bl': return '';
|
||||
case 'ph': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-cm">"???" <span style="color:#e06c75;font-style:italic">${esc(line.hint||'')}</span></span>`; break;
|
||||
case 'kw': r=`<span class="py-kw">${esc(line.v)}</span><span class="py-val">${esc(line.rest||'')}</span>`; break;
|
||||
case 'ass': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-str">${esc(String(line.val))}</span>`; break;
|
||||
case 'ass-end':r=`<span class="py-punc">${esc(line.v)}</span>`; break;
|
||||
case 'fn-def': r=`<span class="py-kw">def </span><span class="py-fn">${esc(line.name)}</span><span class="py-punc">(${esc(line.args||'')})</span><span class="py-punc">:</span>`; break;
|
||||
case 'ind': r=` <span class="py-val">${esc(line.v)}</span>`; break;
|
||||
case 'ind2': r=` <span class="py-val">${esc(line.v)}</span>`; break;
|
||||
case 'ind-cm': r=` <span class="py-cm">${esc(line.v)}</span>`; break;
|
||||
default: return esc(line.v||'');
|
||||
}
|
||||
return line.hi ? `<span class="py-hi">${r}</span>` : r;
|
||||
}
|
||||
|
||||
let codeTypingTimer = null;
|
||||
let lastLineCount = 0;
|
||||
let lastKey_scroll = null; // prevent scroll thrash
|
||||
|
||||
function updateCodePane(){
|
||||
const lines = buildPythonCode();
|
||||
const codeEl = document.getElementById('codeContent');
|
||||
const numsEl = document.getElementById('codeLineNums');
|
||||
const linesEl= document.getElementById('codeLines');
|
||||
if(!codeEl) return;
|
||||
|
||||
// Filename
|
||||
const dev = ST.devName ? ST.devName.toLowerCase().replace(/[^a-z0-9]/g,'_') : 'dev';
|
||||
const gn = ST.name ? ST.name.toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20) : 'boardgame';
|
||||
const fnEl = document.getElementById('codeFilename');
|
||||
if(fnEl) fnEl.textContent = (ST.name||ST.devName) ? `${gn}_von_${dev}.py` : 'boardgame.py';
|
||||
|
||||
if(lines.length===0){
|
||||
codeEl.innerHTML='<span class="py-cm"># Klicke ein Feld an — dein Code erscheint hier ✨</span>';
|
||||
numsEl.innerHTML='<div>1</div>';
|
||||
if(linesEl) linesEl.textContent='1 Zeile';
|
||||
lastLineCount=0; return;
|
||||
}
|
||||
|
||||
let html='', lineNum=1;
|
||||
const lineNums=[];
|
||||
lines.forEach(line=>{
|
||||
if(line.t==='bl'){ html+='\n'; }
|
||||
else { html+=renderToken(line)+'\n'; }
|
||||
lineNums.push(lineNum++);
|
||||
});
|
||||
|
||||
const newCount = lines.length;
|
||||
const grew = newCount > lastLineCount;
|
||||
const prevCount= lastLineCount;
|
||||
lastLineCount = newCount;
|
||||
|
||||
codeEl.innerHTML = html;
|
||||
numsEl.innerHTML = lineNums.map(n=>`<div>${n}</div>`).join('');
|
||||
if(linesEl) linesEl.textContent = lineNum+' Zeilen';
|
||||
|
||||
const status = document.getElementById('codeStatus');
|
||||
if(status){ status.textContent='● Live'; setTimeout(()=>{ status.textContent='● Bereit'; },800); }
|
||||
|
||||
// Scroll: only when new lines appeared AND key changed (avoid thrash while typing)
|
||||
const scroll = document.getElementById('codeScroll');
|
||||
if(scroll && grew && lastKey !== lastKey_scroll){
|
||||
lastKey_scroll = lastKey;
|
||||
// Find the first highlighted line's position
|
||||
setTimeout(()=>{
|
||||
const hiEl = codeEl.querySelector('.py-hi');
|
||||
if(hiEl){
|
||||
const pre = codeEl;
|
||||
const preTop = pre.getBoundingClientRect().top;
|
||||
const hiTop = hiEl.getBoundingClientRect().top;
|
||||
const offset = hiTop - preTop;
|
||||
const scrollTarget = scroll.scrollTop + offset - 80;
|
||||
scroll.scrollTo({top: Math.max(0, scrollTarget), behavior:'smooth'});
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCodeUpdate(delay=80){
|
||||
clearTimeout(codeTypingTimer);
|
||||
codeTypingTimer = setTimeout(updateCodePane, delay);
|
||||
}
|
||||
|
||||
document.addEventListener('input', ()=>scheduleCodeUpdate(50));
|
||||
document.addEventListener('change', ()=>scheduleCodeUpdate(50));
|
||||
|
||||
// Initial render
|
||||
setTimeout(updateCodePane, 200);
|
||||
1664
editor.html
Normal file
1664
editor.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
logo.png
Normal file
BIN
logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
93
minigames/_api.js
Normal file
93
minigames/_api.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* minigames/_api.js
|
||||
* Gemeinsame API und Hilfsfunktionen für alle Mini-Games
|
||||
*
|
||||
* Jedes Mini-Game muss folgendes exportieren (als globale Variable window.MG_<NAME>):
|
||||
*
|
||||
* window.MG_snake = {
|
||||
* id: 'snake',
|
||||
* emoji: '🐍',
|
||||
* name: 'Snake',
|
||||
* desc: 'Steuere die Schlange...',
|
||||
* controls: 'Pfeiltasten oder WASD',
|
||||
* multi: 1, // 1 = einmalig, 3 = max 3x, 99 = unbegrenzt
|
||||
* launch: function(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||
* preview: function(wrap, W, H, cfg) { ... return { stop() {} }; },
|
||||
* };
|
||||
*
|
||||
* launch() → vollständiges Spiel, wird in game.html verwendet
|
||||
* preview() → kompakte Demo, wird im Editor-Test-Popup verwendet
|
||||
* Beide geben ein Objekt { stop() } zurück zum Aufräumen.
|
||||
*
|
||||
* cfg = { quizData, theme, rules, devName, gameName }
|
||||
*/
|
||||
|
||||
window.MGAPI = (function() {
|
||||
|
||||
// ── Canvas-Setup-Helfer ──────────────────────────────────────────────────
|
||||
function makeCanvas(wrap, W, H) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.margin = '0 auto';
|
||||
canvas.style.borderRadius = '12px';
|
||||
canvas.style.background = '#0f0e17';
|
||||
wrap.appendChild(canvas);
|
||||
return { canvas, ctx: canvas.getContext('2d') };
|
||||
}
|
||||
|
||||
// ── Ergebnis-Anzeige (wird von game.html überschrieben) ──────────────────
|
||||
// game.html setzt window.MGAPI.onResult = finishMinigame
|
||||
// editor.html setzt window.MGAPI.onResult = previewResult
|
||||
function onResult(won) {
|
||||
if (typeof window._mgOnResult === 'function') {
|
||||
window._mgOnResult(won);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schrift-Helfer ───────────────────────────────────────────────────────
|
||||
function text(ctx, str, x, y, opts = {}) {
|
||||
ctx.save();
|
||||
ctx.font = `${opts.weight || 'bold'} ${opts.size || 16}px ${opts.family || 'Nunito,sans-serif'}`;
|
||||
ctx.fillStyle = opts.color || '#fff';
|
||||
ctx.textAlign = opts.align || 'center';
|
||||
ctx.textBaseline = opts.baseline || 'middle';
|
||||
if (opts.shadow) {
|
||||
ctx.shadowColor = opts.shadow;
|
||||
ctx.shadowBlur = opts.shadowBlur || 10;
|
||||
}
|
||||
ctx.fillText(str, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Runde Rechtecke ──────────────────────────────────────────────────────
|
||||
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||
if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
|
||||
}
|
||||
|
||||
// ── Game-Over / Win Screen ───────────────────────────────────────────────
|
||||
function resultScreen(ctx, W, H, won, msg) {
|
||||
ctx.fillStyle = won ? 'rgba(16,185,129,0.85)' : 'rgba(239,68,68,0.85)';
|
||||
roundRect(ctx, W/2-120, H/2-50, 240, 100, 16, ctx.fillStyle, null);
|
||||
text(ctx, won ? '🎉 Gewonnen!' : '💀 Verloren!', W/2, H/2-16,
|
||||
{ size: 22, weight: 'bold', family: "'Fredoka One',cursive", color: '#fff' });
|
||||
if (msg) text(ctx, msg, W/2, H/2+16, { size: 13, color: 'rgba(255,255,255,0.85)' });
|
||||
}
|
||||
|
||||
// ── Öffentliche API ──────────────────────────────────────────────────────
|
||||
return { makeCanvas, onResult, text, roundRect, resultScreen };
|
||||
|
||||
})();
|
||||
190
minigames/basketball.js
Normal file
190
minigames/basketball.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* minigames/basketball.js
|
||||
* 🏀 Basketball — Wirf den Ball zum richtigen Zeitpunkt!
|
||||
*/
|
||||
window.MG_basketball = (function() {
|
||||
|
||||
const ID = 'basketball';
|
||||
const EMOJI = '🏀';
|
||||
const NAME = 'Basketball';
|
||||
const DESC = 'Drücke zum richtigen Zeitpunkt, um den Ball zu werfen!';
|
||||
const CONTROLS = 'Klick / Leertaste';
|
||||
const MULTI = 3;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#f97316' };
|
||||
const WIN_BASKETS = cfg.winBaskets || 2;
|
||||
|
||||
// Korb-Position
|
||||
const HOOP_X = W * 0.72;
|
||||
const HOOP_Y = H * 0.28;
|
||||
const HOOP_W = 54;
|
||||
const BALL_R = 18;
|
||||
const BALL_START = { x: W * 0.18, y: H * 0.72 };
|
||||
|
||||
let ballX = BALL_START.x;
|
||||
let ballY = BALL_START.y;
|
||||
let vx = 0, vy = 0;
|
||||
let flying = false;
|
||||
let score = 0;
|
||||
let misses = 0;
|
||||
let stopped = false;
|
||||
let raf, endTimer;
|
||||
let result = null; // 'win' | 'lose' | null
|
||||
|
||||
// Powerbar (zeigt an wie weit der Balken)
|
||||
let power = 0;
|
||||
let powerDir = 1;
|
||||
let powerMax = 100;
|
||||
|
||||
function shoot() {
|
||||
if (flying || result) return;
|
||||
// Wurfkraft aus power (0–100), Winkel Richtung Korb
|
||||
const dx = HOOP_X - BALL_START.x;
|
||||
const dy = HOOP_Y - BALL_START.y - 20;
|
||||
const speed = 4 + power * 0.09;
|
||||
const angle = Math.atan2(dy, dx) - 0.18 - (power - 50) * 0.005;
|
||||
vx = Math.cos(angle) * speed;
|
||||
vy = Math.sin(angle) * speed;
|
||||
flying = true;
|
||||
ballX = BALL_START.x;
|
||||
ballY = BALL_START.y;
|
||||
}
|
||||
|
||||
const onAction = e => {
|
||||
if (e.type === 'keydown' && e.code !== 'Space') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
shoot();
|
||||
};
|
||||
document.addEventListener('keydown', onAction);
|
||||
canvas.addEventListener('click', onAction);
|
||||
canvas.addEventListener('touchstart', e => { e.preventDefault(); shoot(); }, { passive: false });
|
||||
|
||||
function reset() {
|
||||
flying = false;
|
||||
ballX = BALL_START.x;
|
||||
ballY = BALL_START.y;
|
||||
}
|
||||
|
||||
function drawHoop() {
|
||||
// Brett
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.fillRect(HOOP_X + HOOP_W * 0.4, HOOP_Y - 50, 8, 50);
|
||||
|
||||
// Korb-Ring
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(HOOP_X + HOOP_W / 2, HOOP_Y, HOOP_W / 2, 8, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Netz (einfach als Linien)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.lineWidth = 1.5;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const nx = HOOP_X + (HOOP_W / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nx, HOOP_Y + 8);
|
||||
ctx.lineTo(HOOP_X + HOOP_W / 2 + (i - 2) * 3, HOOP_Y + 36);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPower() {
|
||||
if (flying || result) return;
|
||||
const bx = 12, by = H - 28, bw = W * 0.45, bh = 14;
|
||||
MGAPI.roundRect(ctx, bx, by, bw, bh, 6, 'rgba(255,255,255,0.1)', 'rgba(255,255,255,0.2)');
|
||||
const fill = Math.max(0, Math.min(1, power / powerMax));
|
||||
const color = fill < 0.4 ? '#22c55e' : fill < 0.7 ? '#f59e0b' : '#ef4444';
|
||||
MGAPI.roundRect(ctx, bx + 2, by + 2, (bw - 4) * fill, bh - 4, 4, color, null);
|
||||
MGAPI.text(ctx, 'Kraft', bx + bw / 2, by + bh / 2, { size: 10, color: '#fff' });
|
||||
}
|
||||
|
||||
function loop(ts) {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Boden
|
||||
MGAPI.roundRect(ctx, 0, H - 10, W, 10, 0, '#1e293b', null);
|
||||
|
||||
drawHoop();
|
||||
|
||||
if (!flying && !result) {
|
||||
// Power-Bar animieren
|
||||
power += powerDir * 1.8;
|
||||
if (power >= powerMax) powerDir = -1;
|
||||
if (power <= 0) powerDir = 1;
|
||||
}
|
||||
|
||||
if (flying) {
|
||||
vy += 0.38; // Schwerkraft
|
||||
ballX += vx;
|
||||
ballY += vy;
|
||||
|
||||
// Korb-Treffer prüfen
|
||||
const inHoopX = ballX > HOOP_X && ballX < HOOP_X + HOOP_W;
|
||||
const inHoopY = Math.abs(ballY - HOOP_Y) < 18;
|
||||
if (inHoopX && inHoopY && vy > 0) {
|
||||
score++;
|
||||
if (score >= WIN_BASKETS && !endTimer)
|
||||
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 300);
|
||||
reset();
|
||||
}
|
||||
|
||||
// Boden oder aus dem Bild
|
||||
if (ballY > H + 20 || ballX > W + 20 || ballX < -20) {
|
||||
misses++;
|
||||
if (misses >= 4 && score < WIN_BASKETS && !endTimer)
|
||||
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 300);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Ball
|
||||
ctx.shadowColor = theme.primary;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.fillStyle = theme.primary;
|
||||
ctx.beginPath();
|
||||
ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Linien auf Ball
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ballX - BALL_R, ballY); ctx.lineTo(ballX + BALL_R, ballY); ctx.stroke();
|
||||
|
||||
drawPower();
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🏀 ${score} / ${WIN_BASKETS}`, W / 2, 18, { size: 14, color: theme.primary });
|
||||
if (!flying && !result)
|
||||
MGAPI.text(ctx, 'Klick oder Leertaste zum Werfen', W / 2, H - 48, { size: 11, color: 'rgba(255,255,255,0.45)' });
|
||||
|
||||
if (result)
|
||||
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||
result === 'win' ? `${score} Körbe getroffen!` : `Nur ${score} — weiter üben!`);
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onAction);
|
||||
canvas.removeEventListener('click', onAction);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
158
minigames/catch.js
Normal file
158
minigames/catch.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* minigames/catch.js
|
||||
* 🍎 Äpfel fangen — Bewege den Korb und fange fallende Früchte!
|
||||
*/
|
||||
window.MG_catch = (function() {
|
||||
|
||||
const ID = 'catch';
|
||||
const EMOJI = '🍎';
|
||||
const NAME = 'Äpfel fangen';
|
||||
const DESC = 'Bewege den Korb mit ← → oder der Maus und fange 5 Äpfel!';
|
||||
const CONTROLS = '← → / A D / Maus';
|
||||
const MULTI = 3;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#22c55e' };
|
||||
const WIN = cfg.winCatch || 5;
|
||||
const FRUITS = ['🍎','🍊','🍋','🍇','🍓','🍑'];
|
||||
const BASKET_W = 70, BASKET_H = 24;
|
||||
|
||||
let basket = { x: W / 2 - BASKET_W / 2, speed: 0 };
|
||||
let items = [];
|
||||
let score = 0;
|
||||
let missed = 0;
|
||||
let frame = 0;
|
||||
let stopped = false;
|
||||
let raf, endTimer, result = null;
|
||||
|
||||
const keys = {};
|
||||
let mouseX = null;
|
||||
|
||||
const onKey = e => { keys[e.code] = e.type === 'keydown'; };
|
||||
const onMouse = e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = (e.clientX - rect.left) * (canvas.width / rect.width) - BASKET_W / 2;
|
||||
};
|
||||
const onTouch = e => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = (e.touches[0].clientX - rect.left) * (canvas.width / rect.width) - BASKET_W / 2;
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('keyup', onKey);
|
||||
canvas.addEventListener('mousemove', onMouse);
|
||||
canvas.addEventListener('touchmove', onTouch, { passive: false });
|
||||
|
||||
function spawnItem() {
|
||||
items.push({
|
||||
x: Math.random() * (W - 40) + 20,
|
||||
y: -30,
|
||||
vy: 1.8 + Math.random() * 1.4 + score * 0.08,
|
||||
emoji: FRUITS[Math.floor(Math.random() * FRUITS.length)],
|
||||
size: 26 + Math.random() * 10,
|
||||
});
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
frame++;
|
||||
|
||||
// Basket bewegen
|
||||
if (mouseX !== null) {
|
||||
basket.x += (mouseX - basket.x) * 0.18;
|
||||
} else {
|
||||
if (keys['ArrowLeft'] || keys['KeyA']) basket.x -= 5.5;
|
||||
if (keys['ArrowRight'] || keys['KeyD']) basket.x += 5.5;
|
||||
}
|
||||
basket.x = Math.max(0, Math.min(W - BASKET_W, basket.x));
|
||||
|
||||
// Früchte spawnen
|
||||
const spawnRate = Math.max(30, 70 - score * 3);
|
||||
if (frame % spawnRate === 0 && !result) spawnItem();
|
||||
|
||||
// Update
|
||||
items.forEach(it => { it.y += it.vy; });
|
||||
|
||||
// Fangen / Vermissen
|
||||
items = items.filter(it => {
|
||||
const basketTop = H - 50 - BASKET_H;
|
||||
if (it.y + it.size / 2 > basketTop &&
|
||||
it.x > basket.x && it.x < basket.x + BASKET_W) {
|
||||
score++;
|
||||
if (score >= WIN && !endTimer)
|
||||
endTimer = setTimeout(() => { result = 'win'; setTimeout(() => onDone(true), 900); }, 200);
|
||||
return false;
|
||||
}
|
||||
if (it.y > H + 20) {
|
||||
missed++;
|
||||
if (missed >= 4 && score < WIN && !endTimer)
|
||||
endTimer = setTimeout(() => { result = 'lose'; setTimeout(() => onDone(false), 900); }, 200);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// ── Zeichnen ──
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Boden-Linie
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, H - 50); ctx.lineTo(W, H - 50); ctx.stroke();
|
||||
|
||||
// Früchte
|
||||
ctx.font = '28px serif';
|
||||
ctx.textAlign = 'center';
|
||||
items.forEach(it => ctx.fillText(it.emoji, it.x, it.y + it.size));
|
||||
|
||||
// Korb
|
||||
const bx = basket.x, by = H - 50 - BASKET_H;
|
||||
MGAPI.roundRect(ctx, bx, by, BASKET_W, BASKET_H, 6, `${theme.primary}33`, theme.primary);
|
||||
ctx.lineWidth = 2.5;
|
||||
// Korb-Linien
|
||||
for (let i = 1; i < 4; i++) {
|
||||
ctx.strokeStyle = `${theme.primary}66`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + (BASKET_W / 4) * i, by);
|
||||
ctx.lineTo(bx + (BASKET_W / 4) * i, by + BASKET_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.font = '22px serif';
|
||||
ctx.fillText('🧺', bx + BASKET_W / 2, by + BASKET_H + 20);
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🍎 ${score} / ${WIN} 💔 ${missed} / 4`, W / 2, 18, { size: 13, color: theme.primary });
|
||||
|
||||
if (!result)
|
||||
MGAPI.text(ctx, '← → oder Maus bewegen', W / 2, H - 10, { size: 10, color: 'rgba(255,255,255,0.3)' });
|
||||
|
||||
if (result)
|
||||
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||
result === 'win' ? `${score} Früchte gefangen!` : `${missed} verpasst — nächstes Mal!`);
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('keyup', onKey);
|
||||
canvas.removeEventListener('mousemove', onMouse);
|
||||
canvas.removeEventListener('touchmove', onTouch);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
129
minigames/flappy.js
Normal file
129
minigames/flappy.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* minigames/flappy.js
|
||||
* 🐦 Flappy Bird — Klicke oder Leertaste, um durch die Röhren zu fliegen!
|
||||
*/
|
||||
window.MG_flappy = (function() {
|
||||
|
||||
const ID = 'flappy';
|
||||
const EMOJI = '🐦';
|
||||
const NAME = 'Flappy Bird';
|
||||
const DESC = 'Klick oder Leertaste, um zu fliegen. 3 Hindernisse schaffen = Sieg!';
|
||||
const CONTROLS = 'Klick / Leertaste';
|
||||
const MULTI = 1;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#06b6d4' };
|
||||
const WIN_PIPES = cfg.winPipes || 3;
|
||||
const GRAV = 0.5, JUMP = -8, PW = 38, GAP = 85;
|
||||
|
||||
let bird = { y: H / 2, vy: 0 };
|
||||
let pipes = [{ x: W, gap: Math.random() * (H - 100) + 30 }];
|
||||
let score = 0;
|
||||
let dead = false;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
let raf, endTimer;
|
||||
|
||||
const jump = () => { if (!dead) { bird.vy = JUMP; started = true; } };
|
||||
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); jump(); } };
|
||||
document.addEventListener('keydown', onKey);
|
||||
canvas.addEventListener('click', jump);
|
||||
// Touch
|
||||
canvas.addEventListener('touchstart', e => { e.preventDefault(); jump(); }, { passive: false });
|
||||
|
||||
function loop() {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Hintergrund-Gradient
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, '#0a0a1a');
|
||||
grad.addColorStop(1, '#050508');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (started && !dead) {
|
||||
bird.vy += GRAV;
|
||||
bird.y += bird.vy;
|
||||
pipes.forEach(p => p.x -= 2.8);
|
||||
if (pipes[pipes.length - 1].x < W - 170)
|
||||
pipes.push({ x: W, gap: Math.random() * (H - 100) + 30 });
|
||||
pipes = pipes.filter(p => p.x > -PW);
|
||||
pipes.forEach(p => { if (p.x + PW < 50 && !p.passed) { p.passed = true; score++; } });
|
||||
|
||||
if (bird.y < 0 || bird.y + 20 > H) dead = true;
|
||||
pipes.forEach(p => {
|
||||
if (50 < p.x + PW && 70 > p.x && (bird.y < p.gap || bird.y + 20 > p.gap + GAP))
|
||||
dead = true;
|
||||
});
|
||||
if (dead && !endTimer)
|
||||
endTimer = setTimeout(() => onDone(score >= WIN_PIPES), 1000);
|
||||
if (score >= WIN_PIPES && !dead && !endTimer)
|
||||
endTimer = setTimeout(() => onDone(true), 400);
|
||||
}
|
||||
|
||||
// Röhren zeichnen
|
||||
pipes.forEach(p => {
|
||||
// Röhren-Körper
|
||||
MGAPI.roundRect(ctx, p.x, 0, PW, p.gap - 8, 4, `${theme.primary}cc`, null);
|
||||
MGAPI.roundRect(ctx, p.x - 3, p.gap - 12, PW + 6, 12, 4, `${theme.primary}ee`, null);
|
||||
MGAPI.roundRect(ctx, p.x, p.gap + GAP + 8, PW, H - p.gap - GAP - 8, 4, `${theme.primary}cc`, null);
|
||||
MGAPI.roundRect(ctx, p.x - 3, p.gap + GAP, PW + 6, 12, 4, `${theme.primary}ee`, null);
|
||||
});
|
||||
|
||||
// Vogel
|
||||
ctx.shadowColor = dead ? '#ef4444' : theme.primary;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillStyle = dead ? '#ef4444' : theme.primary;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(50 + 14, bird.y + 10, 14, 10, bird.vy * 0.04, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Auge
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(50 + 20, bird.y + 7, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(50 + 21, bird.y + 7, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🐦 ${score} / ${WIN_PIPES}`, 8, 14,
|
||||
{ align: 'left', size: 13, color: theme.primary });
|
||||
|
||||
if (!started) {
|
||||
MGAPI.text(ctx, 'Klicken oder Leertaste', W / 2, H / 2 + 40,
|
||||
{ size: 13, color: 'rgba(255,255,255,0.7)' });
|
||||
}
|
||||
|
||||
if (dead) {
|
||||
MGAPI.resultScreen(ctx, W, H, score >= WIN_PIPES,
|
||||
score >= WIN_PIPES ? `${score} Hindernisse geschafft!` : `Nur ${score} — flieg weiter!`);
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
canvas.removeEventListener('click', jump);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, { ...cfg, winPipes: 3 }, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
181
minigames/maze.js
Normal file
181
minigames/maze.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* minigames/maze.js
|
||||
* 🌀 Labyrinth — Finde den Ausgang!
|
||||
*/
|
||||
window.MG_maze = (function() {
|
||||
|
||||
const ID = 'maze';
|
||||
const EMOJI = '🌀';
|
||||
const NAME = 'Labyrinth';
|
||||
const DESC = 'Steuere mit den Pfeiltasten durch das Labyrinth zum Ausgang!';
|
||||
const CONTROLS = 'Pfeiltasten / WASD';
|
||||
const MULTI = 3;
|
||||
|
||||
// ── Maze-Generator (Recursive Backtracker) ────────────────────────────────
|
||||
function generateMaze(COLS, ROWS) {
|
||||
const cells = Array.from({ length: ROWS }, () =>
|
||||
Array.from({ length: COLS }, () => ({ n: true, s: true, e: true, w: true, visited: false }))
|
||||
);
|
||||
const stack = [];
|
||||
let cur = { c: 0, r: 0 };
|
||||
cells[0][0].visited = true;
|
||||
stack.push(cur);
|
||||
|
||||
while (stack.length) {
|
||||
const { c, r } = stack[stack.length - 1];
|
||||
const neighbors = [];
|
||||
if (r > 0 && !cells[r-1][c].visited) neighbors.push({ c, r: r-1, dir: 'n' });
|
||||
if (r < ROWS-1 && !cells[r+1][c].visited) neighbors.push({ c, r: r+1, dir: 's' });
|
||||
if (c < COLS-1 && !cells[r][c+1].visited) neighbors.push({ c: c+1, r, dir: 'e' });
|
||||
if (c > 0 && !cells[r][c-1].visited) neighbors.push({ c: c-1, r, dir: 'w' });
|
||||
|
||||
if (!neighbors.length) { stack.pop(); continue; }
|
||||
const next = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||
cells[r][c][next.dir] = false;
|
||||
const opp = { n:'s', s:'n', e:'w', w:'e' }[next.dir];
|
||||
cells[next.r][next.c][opp] = false;
|
||||
cells[next.r][next.c].visited = true;
|
||||
stack.push({ c: next.c, r: next.r });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#6366f1' };
|
||||
|
||||
const COLS = 9, ROWS = 7;
|
||||
const cellW = Math.floor((W - 16) / COLS);
|
||||
const cellH = Math.floor((H - 40) / ROWS);
|
||||
const OX = (W - COLS * cellW) / 2;
|
||||
const OY = 32;
|
||||
const WALL = 2;
|
||||
|
||||
const maze = generateMaze(COLS, ROWS);
|
||||
let px = 0, py = 0;
|
||||
let stopped = false;
|
||||
let raf, endTimer, won = false;
|
||||
let moveTimer = null;
|
||||
|
||||
function tryMove(dc, dr) {
|
||||
const cell = maze[py][px];
|
||||
const dir = dc === 1 ? 'e' : dc === -1 ? 'w' : dr === 1 ? 's' : 'n';
|
||||
if (cell[dir]) return; // Wand
|
||||
px += dc; py += dr;
|
||||
if (px === COLS - 1 && py === ROWS - 1 && !endTimer) {
|
||||
won = true;
|
||||
endTimer = setTimeout(() => onDone(true), 900);
|
||||
}
|
||||
}
|
||||
|
||||
const held = {};
|
||||
const onKey = e => {
|
||||
const map = {
|
||||
ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0],
|
||||
w: [0,-1], s: [0,1], a: [-1,0], d: [1,0],
|
||||
};
|
||||
const d = map[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
if (e.type === 'keydown') {
|
||||
if (!held[e.key]) tryMove(d[0], d[1]);
|
||||
held[e.key] = true;
|
||||
} else {
|
||||
held[e.key] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Touch-Swipe
|
||||
let touchStart = null;
|
||||
canvas.addEventListener('touchstart', e => {
|
||||
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}, { passive: true });
|
||||
canvas.addEventListener('touchend', e => {
|
||||
if (!touchStart) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStart.x;
|
||||
const dy = e.changedTouches[0].clientY - touchStart.y;
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
tryMove(dx > 0 ? 1 : -1, 0);
|
||||
} else {
|
||||
tryMove(0, dy > 0 ? 1 : -1);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('keyup', onKey);
|
||||
|
||||
function drawMaze() {
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const x = OX + c * cellW;
|
||||
const y = OY + r * cellH;
|
||||
const cell = maze[r][c];
|
||||
|
||||
ctx.strokeStyle = `${theme.primary}88`;
|
||||
ctx.lineWidth = WALL;
|
||||
|
||||
if (cell.n && r === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+cellW,y); ctx.stroke(); }
|
||||
if (cell.w && c === 0) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+cellH); ctx.stroke(); }
|
||||
if (cell.s) { ctx.beginPath(); ctx.moveTo(x,y+cellH); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
|
||||
if (cell.e) { ctx.beginPath(); ctx.moveTo(x+cellW,y); ctx.lineTo(x+cellW,y+cellH); ctx.stroke(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Start & Ziel markieren
|
||||
MGAPI.roundRect(ctx, OX, OY, cellW, cellH, 4, 'rgba(16,185,129,0.2)', null);
|
||||
MGAPI.roundRect(ctx,
|
||||
OX + (COLS-1) * cellW, OY + (ROWS-1) * cellH,
|
||||
cellW, cellH, 4, 'rgba(245,166,35,0.25)', null);
|
||||
|
||||
drawMaze();
|
||||
|
||||
// Start/Ziel-Label
|
||||
ctx.font = '14px serif'; ctx.textAlign = 'center';
|
||||
ctx.fillText('🟢', OX + cellW/2, OY + cellH/2 + 6);
|
||||
ctx.fillText('🏁', OX + (COLS-0.5)*cellW, OY + (ROWS-0.5)*cellH + 6);
|
||||
|
||||
// Spieler
|
||||
const plX = OX + px * cellW + cellW / 2;
|
||||
const plY = OY + py * cellH + cellH / 2;
|
||||
ctx.shadowColor = theme.primary;
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = theme.primary;
|
||||
ctx.beginPath();
|
||||
ctx.arc(plX, plY, Math.min(cellW, cellH) * 0.32, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🌀 Finde den Ausgang!`, W / 2, 16, { size: 12, color: theme.primary });
|
||||
MGAPI.text(ctx, '← ↑ → ↓', W / 2, H - 10, { size: 10, color: 'rgba(255,255,255,0.3)' });
|
||||
|
||||
if (won)
|
||||
MGAPI.resultScreen(ctx, W, H, true, 'Ausgang gefunden!');
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('keyup', onKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
142
minigames/memory.js
Normal file
142
minigames/memory.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* minigames/memory.js
|
||||
* 🃏 Memory — Finde alle Paare, so schnell wie möglich!
|
||||
*/
|
||||
window.MG_memory = (function() {
|
||||
|
||||
const ID = 'memory';
|
||||
const EMOJI = '🃏';
|
||||
const NAME = 'Memory';
|
||||
const DESC = 'Finde alle Paare — so schnell wie möglich!';
|
||||
const CONTROLS = 'Mausklick / Tippen';
|
||||
const MULTI = 1;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#8b5cf6' };
|
||||
const EMOJIS = cfg.emojis || ['🐱','🐶','🦊','🐸','🦋','🐠'];
|
||||
|
||||
const all = [...EMOJIS, ...EMOJIS].sort(() => Math.random() - 0.5);
|
||||
let cards = all.map((e, i) => ({ e, i, open: false }));
|
||||
let flipped = [], matched = [], checking = false, moves = 0;
|
||||
let stopped = false, raf, startTime = null, elapsed = 0;
|
||||
|
||||
// Layout: 4×3
|
||||
const COLS = 4, ROWS = Math.ceil(all.length / 4);
|
||||
const PAD = 8;
|
||||
const cw = Math.floor((W - PAD * (COLS + 1)) / COLS);
|
||||
const ch = Math.floor((H - PAD * (ROWS + 2) - 24) / ROWS);
|
||||
const ox = (W - COLS * cw - PAD * (COLS - 1)) / 2;
|
||||
const oy = 30;
|
||||
|
||||
function cardAt(mx, my) {
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
for (let ci = 0; ci < COLS; ci++) {
|
||||
const idx = ri * COLS + ci;
|
||||
if (idx >= cards.length) continue;
|
||||
const x = ox + ci * (cw + PAD);
|
||||
const y = oy + ri * (ch + PAD);
|
||||
if (mx >= x && mx < x + cw && my >= y && my < y + ch) return cards[idx];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
if (checking) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const mx = (e.clientX - rect.left) * scaleX;
|
||||
const my = (e.clientY - rect.top) * scaleY;
|
||||
const card = cardAt(mx, my);
|
||||
if (!card || card.open || matched.includes(card.i)) return;
|
||||
if (!startTime) startTime = performance.now();
|
||||
|
||||
card.open = true;
|
||||
flipped.push(card);
|
||||
if (flipped.length === 2) {
|
||||
moves++;
|
||||
checking = true;
|
||||
setTimeout(() => {
|
||||
if (flipped[0].e === flipped[1].e) {
|
||||
matched.push(flipped[0].i, flipped[1].i);
|
||||
if (matched.length === cards.length) {
|
||||
setTimeout(() => onDone(true), 600);
|
||||
}
|
||||
} else {
|
||||
flipped.forEach(c => c.open = false);
|
||||
}
|
||||
flipped = [];
|
||||
checking = false;
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', onClick);
|
||||
|
||||
// Karten-Flip-Animation (progress 0–1)
|
||||
const flipAnim = new Map(); // card.i → { prog, dir }
|
||||
|
||||
function loop(ts) {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (startTime) elapsed = (performance.now() - startTime) / 1000;
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🃏 ${matched.length / 2} / ${EMOJIS.length} | Züge: ${moves}`, W / 2, 16,
|
||||
{ size: 12, color: theme.primary });
|
||||
|
||||
// Karten zeichnen
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
for (let ci = 0; ci < COLS; ci++) {
|
||||
const idx = ri * COLS + ci;
|
||||
if (idx >= cards.length) continue;
|
||||
const card = cards[idx];
|
||||
const x = ox + ci * (cw + PAD);
|
||||
const y = oy + ri * (ch + PAD);
|
||||
const isM = matched.includes(card.i);
|
||||
const isOpen = card.open || isM;
|
||||
|
||||
if (isM) {
|
||||
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||
'rgba(16,185,129,0.15)', '#10b981');
|
||||
} else if (isOpen) {
|
||||
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||
`${theme.primary}22`, theme.primary);
|
||||
} else {
|
||||
MGAPI.roundRect(ctx, x, y, cw, ch, 8,
|
||||
'rgba(255,255,255,0.04)', 'rgba(255,255,255,0.1)');
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
ctx.font = `${Math.min(cw, ch) * 0.55}px serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(card.e, x + cw / 2, y + ch / 2 + ch * 0.15);
|
||||
} else {
|
||||
MGAPI.text(ctx, '?', x + cw / 2, y + ch / 2,
|
||||
{ size: Math.floor(ch * 0.35), color: 'rgba(255,255,255,0.2)' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
canvas.removeEventListener('click', onClick);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
137
minigames/puzzle.js
Normal file
137
minigames/puzzle.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* minigames/puzzle.js
|
||||
* 🧩 Zahlen-Puzzle — Schiebe die Kacheln in die richtige Reihenfolge! (15-Puzzle)
|
||||
*/
|
||||
window.MG_puzzle = (function() {
|
||||
|
||||
const ID = 'puzzle';
|
||||
const EMOJI = '🧩';
|
||||
const NAME = 'Zahlen-Puzzle';
|
||||
const DESC = 'Schiebe die Kacheln in die richtige Reihenfolge (1–8)!';
|
||||
const CONTROLS = 'Mausklick / Pfeiltasten';
|
||||
const MULTI = 1;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#14b8a6' };
|
||||
|
||||
const SIZE = 3; // 3×3
|
||||
const PAD = 20;
|
||||
const cellW = Math.floor((W - PAD * 2) / SIZE);
|
||||
const cellH = Math.floor((H - PAD * 2 - 30) / SIZE);
|
||||
const OX = (W - SIZE * cellW) / 2;
|
||||
const OY = 30 + (H - 30 - SIZE * cellH) / 2;
|
||||
|
||||
// Ziel: 1 2 3 / 4 5 6 / 7 8 _
|
||||
const GOAL = [1,2,3,4,5,6,7,8,0];
|
||||
let board = [...GOAL];
|
||||
let moves = 0;
|
||||
let stopped = false;
|
||||
let raf, endTimer, solved = false;
|
||||
|
||||
// Mischeln (100 zufällige Züge)
|
||||
function shuffle() {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ei = board.indexOf(0);
|
||||
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
|
||||
const nbr = [];
|
||||
if (er > 0) nbr.push(ei - SIZE);
|
||||
if (er < SIZE-1) nbr.push(ei + SIZE);
|
||||
if (ec > 0) nbr.push(ei - 1);
|
||||
if (ec < SIZE-1) nbr.push(ei + 1);
|
||||
const ni = nbr[Math.floor(Math.random() * nbr.length)];
|
||||
[board[ei], board[ni]] = [board[ni], board[ei]];
|
||||
}
|
||||
}
|
||||
shuffle();
|
||||
|
||||
function slideAt(idx) {
|
||||
if (solved) return;
|
||||
const ei = board.indexOf(0);
|
||||
const er = Math.floor(ei / SIZE), ec = ei % SIZE;
|
||||
const tr = Math.floor(idx / SIZE), tc = idx % SIZE;
|
||||
const adj = (er === tr && Math.abs(ec - tc) === 1) ||
|
||||
(ec === tc && Math.abs(er - tr) === 1);
|
||||
if (!adj) return;
|
||||
[board[ei], board[idx]] = [board[idx], board[ei]];
|
||||
moves++;
|
||||
if (board.join() === GOAL.join()) {
|
||||
solved = true;
|
||||
endTimer = setTimeout(() => onDone(true), 900);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||
for (let i = 0; i < SIZE * SIZE; i++) {
|
||||
const c = i % SIZE, r = Math.floor(i / SIZE);
|
||||
const x = OX + c * cellW, y = OY + r * cellH;
|
||||
if (mx >= x && mx < x + cellW - 4 && my >= y && my < y + cellH - 4)
|
||||
slideAt(i);
|
||||
}
|
||||
});
|
||||
|
||||
const onKey = e => {
|
||||
const ei = board.indexOf(0);
|
||||
const map = { ArrowUp: SIZE, ArrowDown: -SIZE, ArrowLeft: 1, ArrowRight: -1 };
|
||||
const di = map[e.key];
|
||||
if (di && ei + di >= 0 && ei + di < SIZE * SIZE) {
|
||||
e.preventDefault();
|
||||
slideAt(ei + di);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
function loop() {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
MGAPI.text(ctx, `🧩 Züge: ${moves}`, W / 2, 18, { size: 12, color: theme.primary });
|
||||
|
||||
for (let i = 0; i < SIZE * SIZE; i++) {
|
||||
const val = board[i];
|
||||
const c = i % SIZE, r = Math.floor(i / SIZE);
|
||||
const x = OX + c * cellW, y = OY + r * cellH;
|
||||
|
||||
if (val === 0) {
|
||||
// Leerfeld
|
||||
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 8, 'rgba(255,255,255,0.03)', null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isCorrect = val === GOAL[i];
|
||||
MGAPI.roundRect(ctx, x+2, y+2, cellW-8, cellH-8, 10,
|
||||
isCorrect ? `${theme.primary}33` : 'rgba(255,255,255,0.07)',
|
||||
isCorrect ? theme.primary : 'rgba(255,255,255,0.15)');
|
||||
|
||||
MGAPI.text(ctx, String(val), x + cellW/2 - 2, y + cellH/2 + 2,
|
||||
{ size: Math.floor(cellH * 0.38), family: "'Fredoka One',cursive",
|
||||
color: isCorrect ? theme.primary : '#fff' });
|
||||
}
|
||||
|
||||
if (solved)
|
||||
MGAPI.resultScreen(ctx, W, H, true, `In ${moves} Zügen gelöst!`);
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(endTimer);
|
||||
cancelAnimationFrame(raf);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
111
minigames/quiz.js
Normal file
111
minigames/quiz.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* minigames/quiz.js
|
||||
* ❓ Quiz — Beantworte die Frage richtig!
|
||||
*/
|
||||
window.MG_quiz = (function() {
|
||||
|
||||
const ID = 'quiz';
|
||||
const EMOJI = '❓';
|
||||
const NAME = 'Quiz';
|
||||
const DESC = 'Beantworte die Frage richtig!';
|
||||
const CONTROLS = 'Mausklick / Tippen';
|
||||
const MULTI = 99; // unbegrenzt, je Quiz-Feld eine Frage
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const theme = cfg.theme || { primary: '#f59e0b' };
|
||||
const quizData = cfg.quizData || [];
|
||||
const fieldIdx = cfg.fieldIndex ?? null;
|
||||
|
||||
// Passende Frage suchen
|
||||
let q = null;
|
||||
if (fieldIdx !== null) q = quizData.find(d => d.fieldIndex === fieldIdx);
|
||||
if (!q && quizData.length > 0) q = quizData[Math.floor(Math.random() * quizData.length)];
|
||||
|
||||
// Fallback wenn keine Frage hinterlegt
|
||||
if (!q || !q.question) {
|
||||
wrap.innerHTML = `
|
||||
<div style="text-align:center;padding:40px 20px;font-family:'Nunito',sans-serif">
|
||||
<div style="font-size:3rem;margin-bottom:12px">❓</div>
|
||||
<div style="color:#a7a3c2;font-size:14px;margin-bottom:20px">Keine Quiz-Frage für dieses Feld hinterlegt.</div>
|
||||
<button onclick="MGAPI.onResult(true)"
|
||||
style="background:linear-gradient(135deg,#7c3aed,#f5a623);color:#fff;font-family:'Fredoka One',cursive;
|
||||
font-size:1rem;border:none;border-radius:10px;padding:12px 28px;cursor:pointer">
|
||||
✅ Trotzdem bestanden!
|
||||
</button>
|
||||
</div>`;
|
||||
return { stop() {} };
|
||||
}
|
||||
|
||||
// HTML-Quiz (kein Canvas — besser lesbar)
|
||||
const answers = q.answers || [];
|
||||
const correct = q.correct ?? 0;
|
||||
const COLORS = ['#7c3aed','#2563eb','#059669','#d97706'];
|
||||
const LABELS = ['A','B','C','D'];
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
display:flex;flex-direction:column;gap:10px;padding:16px;
|
||||
font-family:'Nunito',sans-serif;width:100%;box-sizing:border-box;
|
||||
`;
|
||||
|
||||
// Frage
|
||||
const qEl = document.createElement('div');
|
||||
qEl.style.cssText = `
|
||||
background:rgba(255,255,255,0.05);border-radius:12px;padding:16px;
|
||||
color:#fff;font-size:15px;font-weight:700;line-height:1.5;text-align:center;
|
||||
`;
|
||||
qEl.textContent = q.question;
|
||||
container.appendChild(qEl);
|
||||
|
||||
// Antworten
|
||||
let answered = false;
|
||||
answers.forEach((a, i) => {
|
||||
if (!a) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = `
|
||||
background:${COLORS[i] || '#333'}22;border:2px solid ${COLORS[i] || '#333'}88;
|
||||
color:#fff;border-radius:10px;padding:12px 16px;cursor:pointer;
|
||||
font-family:'Nunito',sans-serif;font-size:13px;font-weight:700;
|
||||
text-align:left;transition:all 0.2s;display:flex;gap:10px;align-items:center;
|
||||
`;
|
||||
btn.innerHTML = `<span style="background:${COLORS[i]};border-radius:6px;padding:2px 8px;font-size:12px">${LABELS[i]}</span> ${a}`;
|
||||
btn.addEventListener('click', () => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
const won = (i === correct);
|
||||
// Feedback-Farben
|
||||
answers.forEach((_, j) => {
|
||||
const b = container.querySelectorAll('button')[j];
|
||||
if (!b) return;
|
||||
if (j === correct) {
|
||||
b.style.background = 'rgba(16,185,129,0.3)';
|
||||
b.style.borderColor = '#10b981';
|
||||
} else if (j === i && !won) {
|
||||
b.style.background = 'rgba(239,68,68,0.3)';
|
||||
b.style.borderColor = '#ef4444';
|
||||
}
|
||||
b.style.cursor = 'default';
|
||||
});
|
||||
// Ergebnis-Text
|
||||
const result = document.createElement('div');
|
||||
result.style.cssText = `
|
||||
text-align:center;font-family:'Fredoka One',cursive;font-size:1.2rem;
|
||||
padding:10px;color:${won ? '#10b981' : '#ef4444'};
|
||||
`;
|
||||
result.textContent = won ? '🎉 Richtig!' : `❌ Falsch! Richtig wäre: ${answers[correct]}`;
|
||||
container.appendChild(result);
|
||||
setTimeout(() => onDone(won), 1400);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
||||
wrap.appendChild(container);
|
||||
return { stop() { wrap.innerHTML = ''; } };
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
144
minigames/reaction.js
Normal file
144
minigames/reaction.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* minigames/reaction.js
|
||||
* ⚡ Reaktionstest — Drück den Knopf so schnell wie möglich!
|
||||
*/
|
||||
window.MG_reaction = (function() {
|
||||
|
||||
const ID = 'reaction';
|
||||
const EMOJI = '⚡';
|
||||
const NAME = 'Reaktionstest';
|
||||
const DESC = 'Wenn der Kreis GRÜN wird — so schnell wie möglich klicken!';
|
||||
const CONTROLS = 'Klick / Leertaste';
|
||||
const MULTI = 3;
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#ef4444' };
|
||||
const ROUNDS = 3;
|
||||
const WIN_MS = 600;
|
||||
|
||||
let phase = 'wait';
|
||||
let waitEnd = 0;
|
||||
let reStart = 0;
|
||||
let times = [];
|
||||
let best = Infinity;
|
||||
let stopped = false;
|
||||
let raf, endTimer;
|
||||
|
||||
function nextWait() {
|
||||
phase = 'wait';
|
||||
waitEnd = performance.now() + (2 + Math.random() * 3) * 1000;
|
||||
}
|
||||
nextWait();
|
||||
|
||||
const react = () => {
|
||||
if (phase === 'ready') {
|
||||
const t = performance.now() - reStart;
|
||||
times.push(t);
|
||||
best = Math.min(best, t);
|
||||
if (times.length >= ROUNDS) {
|
||||
clearTimeout(endTimer);
|
||||
endTimer = setTimeout(() => onDone(best < WIN_MS), 800);
|
||||
phase = 'done';
|
||||
} else {
|
||||
nextWait();
|
||||
}
|
||||
} else if (phase === 'wait') {
|
||||
phase = 'toosoon';
|
||||
setTimeout(nextWait, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = e => { if (e.code === 'Space') { e.preventDefault(); react(); } };
|
||||
document.addEventListener('keydown', onKey);
|
||||
canvas.addEventListener('click', react);
|
||||
|
||||
function loop(ts) {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
if (phase === 'wait' && ts > waitEnd) {
|
||||
phase = 'ready';
|
||||
reStart = performance.now();
|
||||
}
|
||||
|
||||
// Hintergrund
|
||||
const bg = phase === 'ready' ? '#14532d'
|
||||
: phase === 'toosoon' ? '#7f1d1d'
|
||||
: phase === 'done' ? '#0f0e17'
|
||||
: '#050508';
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const cx = W / 2, cy = H / 2;
|
||||
|
||||
if (phase === 'wait') {
|
||||
// Pulsierender Kreis (rot = warten)
|
||||
const pulse = 0.85 + 0.15 * Math.sin(ts * 0.003);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 60 * pulse, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.2)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ef444488';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
MGAPI.text(ctx, '⏳', cx, cy - 6, { size: 36 });
|
||||
MGAPI.text(ctx, 'Warte...', cx, cy + 44, { size: 14, color: '#a7a3c2' });
|
||||
MGAPI.text(ctx, `${times.length} / ${ROUNDS} Runden`, cx, H - 20, { size: 12, color: '#64748b' });
|
||||
}
|
||||
|
||||
if (phase === 'ready') {
|
||||
// Leuchtender grüner Kreis
|
||||
ctx.shadowColor = '#22c55e';
|
||||
ctx.shadowBlur = 40;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 65, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(22,163,74,0.4)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#22c55e';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
MGAPI.text(ctx, '⚡', cx, cy - 8, { size: 44 });
|
||||
MGAPI.text(ctx, 'JETZT!', cx, cy + 48,
|
||||
{ size: 22, family: "'Fredoka One',cursive", color: '#fff', shadow: '#22c55e', shadowBlur: 15 });
|
||||
MGAPI.text(ctx, 'Klick oder Leertaste!', cx, cy + 78, { size: 13, color: 'rgba(255,255,255,0.6)' });
|
||||
}
|
||||
|
||||
if (phase === 'toosoon') {
|
||||
MGAPI.text(ctx, '😅', cx, cy - 10, { size: 48 });
|
||||
MGAPI.text(ctx, 'Zu früh!', cx, cy + 46,
|
||||
{ size: 22, family: "'Fredoka One',cursive", color: '#fca5a5' });
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
MGAPI.resultScreen(ctx, W, H, best < WIN_MS,
|
||||
best < WIN_MS ? `${Math.round(best)} ms — super schnell!` : `${Math.round(best)} ms — noch etwas langsam`);
|
||||
}
|
||||
|
||||
// Statistik
|
||||
if (times.length > 0 && phase !== 'done') {
|
||||
const last = times[times.length - 1];
|
||||
MGAPI.text(ctx, `Letzte: ${Math.round(last)} ms | Beste: ${Math.round(best)} ms`,
|
||||
cx, H - 14, { size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
canvas.removeEventListener('click', react);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
169
minigames/simon.js
Normal file
169
minigames/simon.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* minigames/simon.js
|
||||
* 🔴 Simon Says — Merke und wiederhole die Farb-Sequenz!
|
||||
*/
|
||||
window.MG_simon = (function() {
|
||||
|
||||
const ID = 'simon';
|
||||
const EMOJI = '🔴';
|
||||
const NAME = 'Simon Says';
|
||||
const DESC = 'Merke die Farb-Reihenfolge und tippe sie nach!';
|
||||
const CONTROLS = 'Mausklick / Tippen';
|
||||
const MULTI = 3;
|
||||
|
||||
const COLORS = [
|
||||
{ id: 0, fill: '#22c55e', glow: 'rgba(34,197,94,0.6)', label: '🟢' },
|
||||
{ id: 1, fill: '#ef4444', glow: 'rgba(239,68,68,0.6)', label: '🔴' },
|
||||
{ id: 2, fill: '#3b82f6', glow: 'rgba(59,130,246,0.6)', label: '🔵' },
|
||||
{ id: 3, fill: '#f59e0b', glow: 'rgba(245,158,11,0.6)', label: '🟡' },
|
||||
];
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const WIN_ROUNDS = cfg.winRounds || 4;
|
||||
|
||||
// Layout: 2×2 Grid
|
||||
const PAD = 16;
|
||||
const GW = (W - PAD * 3) / 2;
|
||||
const GH = (H - PAD * 3 - 32) / 2;
|
||||
const POS = [
|
||||
{ x: PAD, y: 32 + PAD },
|
||||
{ x: PAD*2+GW, y: 32 + PAD },
|
||||
{ x: PAD, y: 32 + PAD*2 + GH },
|
||||
{ x: PAD*2+GW, y: 32 + PAD*2 + GH },
|
||||
];
|
||||
|
||||
let sequence = [];
|
||||
let userSeq = [];
|
||||
let showIdx = -1; // welcher Schritt der Anzeige läuft
|
||||
let phase = 'show'; // 'show' | 'input' | 'result'
|
||||
let litIdx = -1;
|
||||
let stopped = false;
|
||||
let raf, stepTimer, endTimer, result = null;
|
||||
|
||||
function addStep() { sequence.push(Math.floor(Math.random() * 4)); }
|
||||
function startShow() {
|
||||
phase = 'show';
|
||||
litIdx = -1;
|
||||
showIdx = 0;
|
||||
userSeq = [];
|
||||
stepTimer = setInterval(() => {
|
||||
litIdx = (litIdx === -1) ? sequence[showIdx] : -1;
|
||||
if (litIdx === -1) {
|
||||
showIdx++;
|
||||
if (showIdx >= sequence.length) {
|
||||
clearInterval(stepTimer);
|
||||
litIdx = -1;
|
||||
phase = 'input';
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function checkInput(colorId) {
|
||||
if (phase !== 'input') return;
|
||||
userSeq.push(colorId);
|
||||
const idx = userSeq.length - 1;
|
||||
|
||||
if (userSeq[idx] !== sequence[idx]) {
|
||||
// Falsch
|
||||
clearTimeout(endTimer);
|
||||
result = 'lose';
|
||||
endTimer = setTimeout(() => onDone(false), 1000);
|
||||
phase = 'result';
|
||||
return;
|
||||
}
|
||||
|
||||
if (userSeq.length === sequence.length) {
|
||||
if (sequence.length >= WIN_ROUNDS) {
|
||||
clearTimeout(endTimer);
|
||||
result = 'win';
|
||||
endTimer = setTimeout(() => onDone(true), 900);
|
||||
phase = 'result';
|
||||
} else {
|
||||
// Nächste Runde
|
||||
phase = 'wait';
|
||||
setTimeout(() => {
|
||||
addStep();
|
||||
startShow();
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Klick auf Farb-Button
|
||||
canvas.addEventListener('click', e => {
|
||||
if (phase !== 'input') return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||
POS.forEach((p, i) => {
|
||||
if (mx >= p.x && mx < p.x + GW && my >= p.y && my < p.y + GH)
|
||||
checkInput(i);
|
||||
});
|
||||
});
|
||||
|
||||
// Starten
|
||||
addStep();
|
||||
startShow();
|
||||
|
||||
function loop() {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Felder zeichnen
|
||||
POS.forEach((p, i) => {
|
||||
const col = COLORS[i];
|
||||
const isLit = litIdx === i;
|
||||
const inUser = phase === 'input' && userSeq[userSeq.length - 1] === i;
|
||||
|
||||
ctx.shadowColor = isLit || inUser ? col.glow : 'transparent';
|
||||
ctx.shadowBlur = isLit || inUser ? 30 : 0;
|
||||
|
||||
MGAPI.roundRect(ctx, p.x, p.y, GW, GH, 12,
|
||||
isLit || inUser ? col.fill : `${col.fill}44`,
|
||||
isLit || inUser ? col.fill : `${col.fill}88`);
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Emoji mittig
|
||||
ctx.font = `${Math.min(GW, GH) * 0.4}px serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(col.label, p.x + GW / 2, p.y + GH / 2 + GH * 0.14);
|
||||
});
|
||||
|
||||
// HUD
|
||||
const phaseText = phase === 'show' ? '👀 Schau zu...'
|
||||
: phase === 'input' ? '👆 Deine Runde!'
|
||||
: phase === 'wait' ? '✅ Richtig!'
|
||||
: '';
|
||||
MGAPI.text(ctx, phaseText, W / 2, 18,
|
||||
{ size: 13, color: phase === 'input' ? '#f59e0b' : '#a7a3c2' });
|
||||
MGAPI.text(ctx, `Runde ${sequence.length} / ${WIN_ROUNDS}`, W / 2, H - 10,
|
||||
{ size: 11, color: 'rgba(255,255,255,0.35)' });
|
||||
|
||||
if (result)
|
||||
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||
result === 'win' ? `${sequence.length} Runden gemeistert!` : 'Falsche Farbe!');
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(stepTimer);
|
||||
clearTimeout(endTimer);
|
||||
cancelAnimationFrame(raf);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
166
minigames/snake.js
Normal file
166
minigames/snake.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* minigames/snake.js
|
||||
* 🐍 Snake — Steuere die Schlange, friss Äpfel ohne gegen die Wand zu fahren
|
||||
*/
|
||||
window.MG_snake = (function() {
|
||||
|
||||
const ID = 'snake';
|
||||
const EMOJI = '🐍';
|
||||
const NAME = 'Snake';
|
||||
const DESC = 'Steuere die Schlange! Friss 3 Äpfel ohne gegen die Wand zu fahren.';
|
||||
const CONTROLS = 'Pfeiltasten oder WASD';
|
||||
const MULTI = 1; // einmalig pro Spiel
|
||||
|
||||
// ── Gemeinsame Spiel-Logik ────────────────────────────────────────────────
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const SZ = 20;
|
||||
const COLS = Math.floor(W / SZ);
|
||||
const ROWS = Math.floor(H / SZ);
|
||||
const WIN_SCORE = cfg.winScore || 3;
|
||||
const theme = cfg.theme || { primary: '#10b981', glow: 'rgba(16,185,129,0.4)' };
|
||||
|
||||
function rndFood() {
|
||||
return {
|
||||
x: Math.floor(Math.random() * COLS),
|
||||
y: Math.floor(Math.random() * ROWS),
|
||||
};
|
||||
}
|
||||
|
||||
let snake = [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }];
|
||||
let dir = { x: 1, y: 0 };
|
||||
let nextDir = { x: 1, y: 0 };
|
||||
let food = rndFood();
|
||||
let score = 0;
|
||||
let dead = false;
|
||||
let stopped = false;
|
||||
let raf, endTimer, last = 0;
|
||||
|
||||
// Steuerung
|
||||
function onKey(e) {
|
||||
const map = {
|
||||
ArrowUp: { x: 0, y: -1 }, w: { x: 0, y: -1 },
|
||||
ArrowDown: { x: 0, y: 1 }, s: { x: 0, y: 1 },
|
||||
ArrowLeft: { x: -1, y: 0 }, a: { x: -1, y: 0 },
|
||||
ArrowRight: { x: 1, y: 0 }, d: { x: 1, y: 0 },
|
||||
};
|
||||
const d = map[e.key];
|
||||
if (d && (d.x !== -dir.x || d.y !== -dir.y)) {
|
||||
e.preventDefault();
|
||||
nextDir = d;
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
// Touch-Steuerung (Swipe)
|
||||
let touchStart = null;
|
||||
canvas.addEventListener('touchstart', e => {
|
||||
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}, { passive: true });
|
||||
canvas.addEventListener('touchend', e => {
|
||||
if (!touchStart) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStart.x;
|
||||
const dy = e.changedTouches[0].clientY - touchStart.y;
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
nextDir = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 };
|
||||
} else {
|
||||
nextDir = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
function drawGrid() {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x < COLS; x++)
|
||||
for (let y = 0; y < ROWS; y++)
|
||||
ctx.strokeRect(x * SZ, y * SZ, SZ, SZ);
|
||||
}
|
||||
|
||||
function loop(ts) {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (ts - last < 140) return;
|
||||
last = ts;
|
||||
|
||||
dir = nextDir;
|
||||
|
||||
if (!dead) {
|
||||
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
|
||||
if (
|
||||
head.x < 0 || head.x >= COLS ||
|
||||
head.y < 0 || head.y >= ROWS ||
|
||||
snake.some(s => s.x === head.x && s.y === head.y)
|
||||
) {
|
||||
dead = true;
|
||||
if (!endTimer) endTimer = setTimeout(() => onDone(score >= WIN_SCORE), 1200);
|
||||
} else {
|
||||
snake.unshift(head);
|
||||
if (head.x === food.x && head.y === food.y) {
|
||||
score++;
|
||||
food = rndFood();
|
||||
if (score >= WIN_SCORE && !endTimer) {
|
||||
endTimer = setTimeout(() => onDone(true), 1200);
|
||||
}
|
||||
} else {
|
||||
snake.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
drawGrid();
|
||||
|
||||
// Futter
|
||||
ctx.shadowColor = theme.primary;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.font = `${SZ - 2}px serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🍎', food.x * SZ + SZ / 2, food.y * SZ + SZ / 1.2);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Schlange
|
||||
snake.forEach((s, i) => {
|
||||
ctx.fillStyle = i === 0 ? theme.primary : `${theme.primary}88`;
|
||||
if (i === 0) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 8; }
|
||||
MGAPI.roundRect(ctx, s.x * SZ + 2, s.y * SZ + 2, SZ - 4, SZ - 4, 4, ctx.fillStyle, null);
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
|
||||
// HUD
|
||||
MGAPI.text(ctx, `🍎 ${score} / ${WIN_SCORE}`, 8, 14,
|
||||
{ align: 'left', size: 13, color: theme.primary });
|
||||
|
||||
// Ergebnis-Screen
|
||||
if (dead) {
|
||||
MGAPI.resultScreen(ctx, W, H, score >= WIN_SCORE,
|
||||
score >= WIN_SCORE ? `${score} Äpfel gefressen!` : `Nur ${score} Äpfel — nächstes Mal!`);
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(endTimer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Vollständiges Spiel (game.html) ──────────────────────────────────────
|
||||
function launch(wrap, W, H, cfg) {
|
||||
return run(wrap, W, H, cfg, won => MGAPI.onResult(won));
|
||||
}
|
||||
|
||||
// ── Editor-Vorschau (Test-Popup) ─────────────────────────────────────────
|
||||
function preview(wrap, W, H, cfg) {
|
||||
return run(wrap, W, H, { ...cfg, winScore: 3 }, won => MGAPI.onResult(won));
|
||||
}
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
202
minigames/spotdiff.js
Normal file
202
minigames/spotdiff.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* minigames/spotdiff.js
|
||||
* 🔍 Fehler finden — Finde alle Unterschiede zwischen den zwei Bildern!
|
||||
*/
|
||||
window.MG_spotdiff = (function() {
|
||||
|
||||
const ID = 'spotdiff';
|
||||
const EMOJI = '🔍';
|
||||
const NAME = 'Fehler finden';
|
||||
const DESC = 'Finde alle Unterschiede zwischen den zwei Bildern!';
|
||||
const CONTROLS = 'Mausklick';
|
||||
const MULTI = 1;
|
||||
|
||||
// ── Bild-Sets (canvas-gezeichnete Szenen) ────────────────────────────────
|
||||
// Jede Szene besteht aus drawA(ctx,W,H) und drawB(ctx,W,H),
|
||||
// plus einer Liste von Unterschied-Hotspots { x, y, r } (relativ, 0–1)
|
||||
const SCENES = [
|
||||
{
|
||||
name: 'Bauernhof',
|
||||
drawA(ctx, W, H) {
|
||||
// Himmel
|
||||
ctx.fillStyle = '#7dd3fc'; ctx.fillRect(0, 0, W, H * 0.55);
|
||||
// Gras
|
||||
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
|
||||
// Sonne
|
||||
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.18, H*0.1, 0, Math.PI*2); ctx.fill();
|
||||
// Haus
|
||||
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
|
||||
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
|
||||
// Fenster (2)
|
||||
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
|
||||
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07);
|
||||
// Tür
|
||||
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.39, H*0.46, W*0.05, H*0.12);
|
||||
// Baum (3 Kreise)
|
||||
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.38, W*0.06, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.46, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.44, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
|
||||
// Wolke
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath(); ctx.arc(W*0.55, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(W*0.62, H*0.11, W*0.06, 0, Math.PI*2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(W*0.69, H*0.14, W*0.05, 0, Math.PI*2); ctx.fill();
|
||||
},
|
||||
drawB(ctx, W, H) {
|
||||
// Himmel (UNTERSCHIED 1: dunkleres Blau)
|
||||
ctx.fillStyle = '#38bdf8'; ctx.fillRect(0, 0, W, H * 0.55);
|
||||
// Gras
|
||||
ctx.fillStyle = '#4ade80'; ctx.fillRect(0, H * 0.55, W, H * 0.45);
|
||||
// Sonne (UNTERSCHIED 2: weiter oben)
|
||||
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(W*0.15, H*0.10, H*0.1, 0, Math.PI*2); ctx.fill();
|
||||
// Haus
|
||||
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.3, H*0.3, W*0.25, H*0.28);
|
||||
ctx.fillStyle = '#7f1d1d'; ctx.beginPath(); ctx.moveTo(W*0.27,H*0.3); ctx.lineTo(W*0.425,H*0.12); ctx.lineTo(W*0.58,H*0.3); ctx.fill();
|
||||
// Fenster — nur EINES (UNTERSCHIED 3)
|
||||
ctx.fillStyle = '#bae6fd'; ctx.fillRect(W*0.34, H*0.38, W*0.06, H*0.07);
|
||||
ctx.fillStyle = '#f87171'; ctx.fillRect(W*0.45, H*0.38, W*0.06, H*0.07); // rotes Fenster
|
||||
// Tür
|
||||
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.39, H*0.46, W*0.05, H*0.12);
|
||||
// Baum (kleiner — UNTERSCHIED 4)
|
||||
ctx.fillStyle = '#16a34a'; ctx.beginPath(); ctx.arc(W*0.75, H*0.42, W*0.04, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#15803d'; ctx.beginPath(); ctx.arc(W*0.72, H*0.48, W*0.035, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#14532d'; ctx.beginPath(); ctx.arc(W*0.79, H*0.46, W*0.035, 0, Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = '#78350f'; ctx.fillRect(W*0.74, H*0.5, W*0.02, H*0.08);
|
||||
// Wolke fehlt (UNTERSCHIED 5)
|
||||
},
|
||||
// Hotspots in Bild B (relativ 0-1)
|
||||
spots: [
|
||||
{ x: 0.42, y: 0.12, r: 0.07, label: 'Himmelfarbe' },
|
||||
{ x: 0.15, y: 0.10, r: 0.08, label: 'Sonne' },
|
||||
{ x: 0.48, y: 0.41, r: 0.06, label: 'Rotes Fenster' },
|
||||
{ x: 0.75, y: 0.44, r: 0.07, label: 'Kleinerer Baum' },
|
||||
{ x: 0.62, y: 0.13, r: 0.08, label: 'Fehlende Wolke' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const scene = SCENES[0];
|
||||
const theme = cfg.theme || { primary: '#84cc16' };
|
||||
|
||||
// Zwei Canvas nebeneinander
|
||||
const halfW = Math.floor((W - 8) / 2);
|
||||
const imgH = H - 64;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:relative;width:100%;';
|
||||
wrap.appendChild(container);
|
||||
|
||||
const canvasA = document.createElement('canvas');
|
||||
canvasA.width = halfW; canvasA.height = imgH;
|
||||
canvasA.style.cssText = `display:inline-block;border-radius:8px;cursor:default;`;
|
||||
const canvasB = document.createElement('canvas');
|
||||
canvasB.width = halfW; canvasB.height = imgH;
|
||||
canvasB.style.cssText = `display:inline-block;border-radius:8px;cursor:crosshair;margin-left:8px;`;
|
||||
|
||||
container.appendChild(canvasA);
|
||||
container.appendChild(canvasB);
|
||||
|
||||
// HUD-Canvas
|
||||
const hud = document.createElement('canvas');
|
||||
hud.width = W; hud.height = 48;
|
||||
hud.style.display = 'block';
|
||||
container.appendChild(hud);
|
||||
|
||||
const ctxA = canvasA.getContext('2d');
|
||||
const ctxB = canvasB.getContext('2d');
|
||||
const ctxH = hud.getContext('2d');
|
||||
|
||||
scene.drawA(ctxA, halfW, imgH);
|
||||
scene.drawB(ctxB, halfW, imgH);
|
||||
|
||||
let found = new Set();
|
||||
let marks = []; // { x, y, ok }
|
||||
let stopped = false;
|
||||
let endTimer;
|
||||
|
||||
function drawMarks() {
|
||||
scene.drawB(ctxB, halfW, imgH); // neu zeichnen
|
||||
marks.forEach(m => {
|
||||
ctxB.strokeStyle = m.ok ? '#22c55e' : '#ef4444';
|
||||
ctxB.lineWidth = 3;
|
||||
ctxB.beginPath();
|
||||
ctxB.arc(m.x, m.y, 18, 0, Math.PI * 2);
|
||||
ctxB.stroke();
|
||||
if (m.ok) {
|
||||
ctxB.fillStyle = 'rgba(34,197,94,0.25)';
|
||||
ctxB.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawHUD() {
|
||||
ctxH.clearRect(0, 0, W, 48);
|
||||
MGAPI.text(ctxH, `🔍 ${found.size} / ${scene.spots.length} Unterschiede gefunden`, W/2, 16,
|
||||
{ size: 13, color: theme.primary });
|
||||
MGAPI.text(ctxH, 'Klicke auf die Unterschiede im rechten Bild', W/2, 36,
|
||||
{ size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||
}
|
||||
|
||||
canvasB.addEventListener('click', e => {
|
||||
if (found.size >= scene.spots.length) return;
|
||||
const rect = canvasB.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) * (canvasB.width / rect.width);
|
||||
const my = (e.clientY - rect.top) * (canvasB.height / rect.height);
|
||||
|
||||
// Nächsten Spot suchen
|
||||
let hit = null;
|
||||
scene.spots.forEach((s, i) => {
|
||||
if (found.has(i)) return;
|
||||
const sx = s.x * halfW, sy = s.y * imgH;
|
||||
const d = Math.sqrt((mx - sx) ** 2 + (my - sy) ** 2);
|
||||
if (d < s.r * Math.min(halfW, imgH)) hit = i;
|
||||
});
|
||||
|
||||
if (hit !== null) {
|
||||
found.add(hit);
|
||||
const sx = scene.spots[hit].x * halfW;
|
||||
const sy = scene.spots[hit].y * imgH;
|
||||
marks.push({ x: sx, y: sy, ok: true });
|
||||
// Auch in Bild A markieren
|
||||
ctxA.strokeStyle = '#22c55e';
|
||||
ctxA.lineWidth = 3;
|
||||
ctxA.beginPath(); ctxA.arc(sx, sy, 18, 0, Math.PI * 2); ctxA.stroke();
|
||||
ctxA.fillStyle = 'rgba(34,197,94,0.2)'; ctxA.fill();
|
||||
} else {
|
||||
marks.push({ x: mx, y: my, ok: false });
|
||||
setTimeout(() => {
|
||||
marks = marks.filter(m => m.ok);
|
||||
drawMarks(); drawHUD();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
drawMarks();
|
||||
drawHUD();
|
||||
|
||||
if (found.size >= scene.spots.length && !endTimer) {
|
||||
endTimer = setTimeout(() => onDone(true), 900);
|
||||
// Overlay
|
||||
ctxH.fillStyle = 'rgba(16,185,129,0.9)';
|
||||
MGAPI.roundRect(ctxH, W/2-120, 4, 240, 38, 8, 'rgba(16,185,129,0.9)', null);
|
||||
MGAPI.text(ctxH, '🎉 Alle Unterschiede gefunden!', W/2, 24,
|
||||
{ size: 14, family: "'Fredoka One',cursive", color: '#fff' });
|
||||
}
|
||||
});
|
||||
|
||||
drawHUD();
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(endTimer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
170
minigames/typing.js
Normal file
170
minigames/typing.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* minigames/typing.js
|
||||
* ⌨️ Tipp-Rennen — Tippe den Text so schnell wie möglich!
|
||||
*/
|
||||
window.MG_typing = (function() {
|
||||
|
||||
const ID = 'typing';
|
||||
const EMOJI = '⌨️';
|
||||
const NAME = 'Tipp-Rennen';
|
||||
const DESC = 'Tippe das angezeigte Wort so schnell wie möglich!';
|
||||
const CONTROLS = 'Tastatur';
|
||||
const MULTI = 3;
|
||||
|
||||
const WORD_POOLS = {
|
||||
easy: ['Katze','Hund','Baum','Haus','Ball','Buch','Schule','Spiel','Kind','Mond'],
|
||||
medium: ['Abenteuer','Computer','Programm','Zauber','Kristall','Phantom','Roboter','Galaxie'],
|
||||
hard: ['Dinosaurier','Wissenschaft','Programmierung','Abenteuerland','Weltentdecker'],
|
||||
};
|
||||
|
||||
function run(wrap, W, H, cfg, onDone) {
|
||||
const { canvas, ctx } = MGAPI.makeCanvas(wrap, W, H);
|
||||
const theme = cfg.theme || { primary: '#f43f5e' };
|
||||
const WIN_WPM = cfg.winWpm || 30; // mind. 30 WPM zum Gewinnen
|
||||
const ROUNDS = 3;
|
||||
|
||||
const allWords = [...WORD_POOLS.easy, ...WORD_POOLS.medium];
|
||||
let words = [];
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
const pool = i < 2 ? WORD_POOLS.easy : WORD_POOLS.medium;
|
||||
words.push(pool[Math.floor(Math.random() * pool.length)]);
|
||||
}
|
||||
|
||||
let round = 0;
|
||||
let typed = '';
|
||||
let startTs = null;
|
||||
let times = [];
|
||||
let stopped = false;
|
||||
let raf, endTimer, result = null;
|
||||
|
||||
// HTML-Input über dem Canvas
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.autocomplete = 'off';
|
||||
input.autocorrect = 'off';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
input.style.cssText = `
|
||||
position:absolute;left:-9999px;top:0;opacity:0;width:1px;height:1px;
|
||||
`;
|
||||
wrap.style.position = 'relative';
|
||||
wrap.appendChild(input);
|
||||
setTimeout(() => input.focus(), 100);
|
||||
canvas.addEventListener('click', () => input.focus());
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
typed = input.value;
|
||||
if (!startTs && typed.length > 0) startTs = performance.now();
|
||||
|
||||
const word = words[round];
|
||||
if (typed.toLowerCase() === word.toLowerCase()) {
|
||||
const elapsed = (performance.now() - startTs) / 1000 / 60; // Minuten
|
||||
const wpm = Math.round(word.length / 5 / elapsed); // Standard: 5 Zeichen = 1 Wort
|
||||
times.push({ word, wpm });
|
||||
typed = '';
|
||||
input.value = '';
|
||||
startTs = null;
|
||||
|
||||
round++;
|
||||
if (round >= ROUNDS) {
|
||||
const avgWpm = Math.round(times.reduce((a, b) => a + b.wpm, 0) / times.length);
|
||||
result = avgWpm >= WIN_WPM ? 'win' : 'lose';
|
||||
endTimer = setTimeout(() => onDone(result === 'win'), 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function loop(ts) {
|
||||
if (stopped) return;
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
ctx.fillStyle = '#050508';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (round >= ROUNDS) {
|
||||
const avgWpm = times.length
|
||||
? Math.round(times.reduce((a,b) => a+b.wpm, 0) / times.length)
|
||||
: 0;
|
||||
MGAPI.resultScreen(ctx, W, H, result === 'win',
|
||||
result === 'win' ? `${avgWpm} WPM — richtig schnell!` : `${avgWpm} WPM — weiter üben!`);
|
||||
return;
|
||||
}
|
||||
|
||||
const word = words[round];
|
||||
const elapsed = startTs ? (ts - startTs) / 1000 : 0;
|
||||
|
||||
// Fortschrittsbalken (Timing-Druck)
|
||||
const timeLimit = 10;
|
||||
const prog = Math.min(1, elapsed / timeLimit);
|
||||
if (prog >= 1 && !endTimer && !result) {
|
||||
result = 'lose';
|
||||
endTimer = setTimeout(() => onDone(false), 800);
|
||||
}
|
||||
|
||||
const barW = W - 40;
|
||||
MGAPI.roundRect(ctx, 20, H - 28, barW, 10, 4, 'rgba(255,255,255,0.07)', null);
|
||||
const barColor = prog < 0.6 ? theme.primary : prog < 0.8 ? '#f59e0b' : '#ef4444';
|
||||
MGAPI.roundRect(ctx, 20, H - 28, barW * (1 - prog), 10, 4, barColor, null);
|
||||
|
||||
// Rundenanzeige
|
||||
MGAPI.text(ctx, `⌨️ Runde ${round + 1} / ${ROUNDS}`, W / 2, 20, { size: 12, color: theme.primary });
|
||||
|
||||
// Zu tippendes Wort
|
||||
MGAPI.roundRect(ctx, W/2-140, H*0.2, 280, 60, 12,
|
||||
'rgba(255,255,255,0.05)', `${theme.primary}44`);
|
||||
MGAPI.text(ctx, word, W / 2, H * 0.2 + 34,
|
||||
{ size: 28, family: "'Fredoka One',cursive", color: '#fff' });
|
||||
|
||||
// Eingabe-Anzeige (zeichenweiser Vergleich)
|
||||
const charW = 26;
|
||||
const startX = W / 2 - (word.length * charW) / 2;
|
||||
const charY = H * 0.55;
|
||||
MGAPI.text(ctx, 'Tippe:', W / 2, charY - 24, { size: 11, color: 'rgba(255,255,255,0.4)' });
|
||||
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
const cx = startX + i * charW + charW / 2;
|
||||
const tc = (typed[i] || '').toLowerCase();
|
||||
const wc = word[i].toLowerCase();
|
||||
let color;
|
||||
if (!typed[i]) color = 'rgba(255,255,255,0.2)';
|
||||
else if (tc === wc) color = '#22c55e';
|
||||
else color = '#ef4444';
|
||||
|
||||
MGAPI.roundRect(ctx, startX + i * charW, charY - 4, charW - 2, 32, 4,
|
||||
'rgba(255,255,255,0.04)', `${color}66`);
|
||||
MGAPI.text(ctx, typed[i] || word[i], cx, charY + 12, { size: 18, color });
|
||||
}
|
||||
|
||||
// Cursor-Blinken
|
||||
if (startTs || typed.length === 0) {
|
||||
const cx = startX + Math.min(typed.length, word.length) * charW + 2;
|
||||
if (Math.floor(ts / 500) % 2 === 0) {
|
||||
ctx.fillStyle = theme.primary;
|
||||
ctx.fillRect(cx, charY, 3, 32);
|
||||
}
|
||||
}
|
||||
|
||||
// letzten WPM anzeigen
|
||||
if (times.length > 0) {
|
||||
const last = times[times.length - 1];
|
||||
MGAPI.text(ctx, `Letztes: ${last.wpm} WPM`, W / 2, H - 42, { size: 11, color: 'rgba(255,255,255,0.35)' });
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(endTimer);
|
||||
cancelAnimationFrame(raf);
|
||||
if (input.parentNode) input.parentNode.removeChild(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function launch(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
function preview(wrap, W, H, cfg) { return run(wrap, W, H, cfg, won => MGAPI.onResult(won)); }
|
||||
|
||||
return { id: ID, emoji: EMOJI, name: NAME, desc: DESC, controls: CONTROLS, multi: MULTI, launch, preview };
|
||||
|
||||
})();
|
||||
Loading…
Reference in a new issue