fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

View File

@@ -0,0 +1,24 @@
# ==============================================
# Breakpilot Drive - WebGL Game Container
# ==============================================
# Dient den Unity WebGL Build ueber Nginx
# Unterstuetzt Gzip-Kompression fuer grosse WASM-Dateien
FROM nginx:alpine
# WebGL-spezifische MIME-Types und Konfiguration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# WebGL Build kopieren (wird vom Unity Build Process befuellt)
# Im Entwicklungsmodus: Placeholder-Seite
COPY Build/ /usr/share/nginx/html/Build/
COPY TemplateData/ /usr/share/nginx/html/TemplateData/
COPY index.html /usr/share/nginx/html/
# Health-Check Endpunkt
RUN echo '{"status":"healthy","service":"breakpilot-drive"}' > /usr/share/nginx/html/health.json
EXPOSE 80
# Nginx im Vordergrund starten
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,377 @@
# Breakpilot Drive - Prefab Konfiguration
Diese Anleitung beschreibt, wie die Prefabs fuer das Spiel konfiguriert werden.
---
## 1. Spieler-Auto (Player)
### Prefab erstellen
1. Aus Kenney Car Kit ein Auto waehlen
2. In die Szene ziehen
3. Umbenennen zu "PlayerCar"
### Komponenten
| Komponente | Einstellungen |
|------------|---------------|
| **Transform** | Position: (0, 0, 0), Scale: (1, 1, 1) |
| **Rigidbody** | Use Gravity: false, Is Kinematic: true |
| **Box Collider** | Is Trigger: false, Size an Auto anpassen |
| **PlayerController** | Lane Distance: 3, Lane Switch Speed: 10 |
### Inspector-Einstellungen (PlayerController)
```
Bewegung:
Lane Distance: 3
Lane Switch Speed: 10
Forward Speed: 10
Spuren:
Current Lane: 1 (Mitte)
Touch-Steuerung:
Swipe Threshold: 50
Effekte:
Crash Effect: (ParticleSystem zuweisen)
Collect Effect: (ParticleSystem zuweisen)
```
### Tag
- Tag: **Player**
### Speichern
- Aus Hierarchy in `Assets/Prefabs/Player/` ziehen
- Name: `PlayerCar.prefab`
---
## 2. Strecken-Segment (Track)
### Prefab erstellen
1. Leeres GameObject: "TrackSegment"
2. Boden erstellen:
- 3D Object → Plane
- Scale: (5, 1, 5) fuer 50m Laenge
- Material: Strassen-Textur
### Struktur
```
TrackSegment
├── Ground (Plane mit Strassen-Material)
├── LeftBorder (optional, Leitplanke)
├── RightBorder (optional, Leitplanke)
└── SpawnPoints (leeres GO fuer Hindernis-Positionen)
```
### Speichern
- Mehrere Varianten erstellen (gerade, Kurve, etc.)
- In `Assets/Prefabs/Track/` speichern
---
## 3. Hindernisse (Obstacles)
### Hindernis-Prefab erstellen
1. Aus Kenney Nature Kit: Stein, Baum, etc. waehlen
2. In Szene ziehen
3. Umbenennen (z.B. "Obstacle_Rock")
### Komponenten
| Komponente | Einstellungen |
|------------|---------------|
| **Box Collider** | Is Trigger: true, Size anpassen |
### Tag
- Tag: **Obstacle**
### Prefab-Varianten
| Name | Beschreibung | Groesse |
|------|--------------|---------|
| `Obstacle_Rock.prefab` | Kleiner Stein | 1 Spur |
| `Obstacle_Tree.prefab` | Umgefallener Baum | 2 Spuren |
| `Obstacle_Barrier.prefab` | Absperrung | 1-2 Spuren |
| `Obstacle_Cone.prefab` | Verkehrskegel | 1 Spur |
### Speichern
- In `Assets/Prefabs/Obstacles/` speichern
---
## 4. Sammelbare Items
### Coin-Prefab
1. 3D Object → Cylinder (oder Kenney Asset)
2. Scale: (0.5, 0.1, 0.5)
3. Material: Gold
| Komponente | Einstellungen |
|------------|---------------|
| **Sphere Collider** | Is Trigger: true, Radius: 0.5 |
| **Rotation Script** | (optional, fuer Dreh-Animation) |
- Tag: **Coin**
- Prefab: `Assets/Prefabs/Items/Coin.prefab`
### Star-Prefab
1. Star-Modell (oder Kenney Asset)
2. Groesser als Coin
| Komponente | Einstellungen |
|------------|---------------|
| **Sphere Collider** | Is Trigger: true, Radius: 0.7 |
- Tag: **Star**
- Prefab: `Assets/Prefabs/Items/Star.prefab`
### Shield-Prefab
1. Shield-Modell
2. Blauer Schimmer-Effekt
| Komponente | Einstellungen |
|------------|---------------|
| **Sphere Collider** | Is Trigger: true, Radius: 0.7 |
- Tag: **Shield**
- Prefab: `Assets/Prefabs/Items/Shield.prefab`
---
## 5. Quiz-Trigger (Visual Triggers)
### Bridge-Prefab
1. Aus Kenney Assets oder selbst erstellen
2. Muss ueber der Strasse sein (Y-Position erhoehen)
### Struktur
```
Bridge
├── BridgeModel (visuelles Modell)
└── TriggerZone (leeres GO mit Collider)
└── Box Collider (Is Trigger: true)
└── VisualTrigger.cs (Trigger Type: "bridge")
```
### Komponenten (TriggerZone)
| Komponente | Einstellungen |
|------------|---------------|
| **Box Collider** | Is Trigger: true, Size: (10, 5, 2) |
| **VisualTrigger** | Trigger Type: "bridge" |
- Tag: **QuizTrigger**
### Weitere Trigger
| Prefab | Trigger Type | Beschreibung |
|--------|--------------|--------------|
| `Bridge.prefab` | "bridge" | Bruecke ueber Strasse |
| `Tree.prefab` | "tree" | Grosser Baum am Rand |
| `House.prefab` | "house" | Haus am Rand |
| `Sign.prefab` | "sign" | Schild mit Bild |
### Speichern
- In `Assets/Prefabs/VisualTriggers/` speichern
---
## 6. Manager-Objekte
Diese werden nicht als Prefabs, sondern direkt in der Szene erstellt:
### GameManager
```
GameObject: GameManager
Scripts:
- GameManager.cs
Inspector:
Start Lives: 3
Pause Question Interval: 500
Game Over Panel: (UI Referenz)
Pause Panel: (UI Referenz)
```
### QuizManager
```
GameObject: QuizManager
Scripts:
- QuizManager.cs
Inspector:
Quick Quiz Panel: (UI Referenz)
Pause Quiz Panel: (UI Referenz)
Question Text: (TextMeshPro Referenz)
Answer Buttons: [Button0, Button1, Button2, Button3]
Timer Text: (TextMeshPro Referenz)
Timer Slider: (Slider Referenz)
Points Correct: 500
Points Wrong: -100
Pause Question Interval: 500
```
### TrackGenerator
```
GameObject: TrackGenerator
Scripts:
- TrackGenerator.cs
Inspector:
Track Segment Prefabs: [Segment1, Segment2, ...]
Start Segment Prefab: (Start-Segment)
Segment Length: 50
Visible Segments: 5
Despawn Distance: -20
Base Speed: 10
```
### ObstacleSpawner
```
GameObject: ObstacleSpawner
Scripts:
- ObstacleSpawner.cs
Inspector:
Obstacle Prefabs: [Rock, Tree, Barrier, ...]
Large Obstacle Prefabs: [LargeTree, ...]
Coin Prefab: (Coin.prefab)
Star Prefab: (Star.prefab)
Shield Prefab: (Shield.prefab)
Bridge Prefab: (Bridge.prefab)
Tree Prefab: (Tree.prefab)
House Prefab: (House.prefab)
Lane Distance: 3
Min Obstacle Spacing: 10
Max Obstacle Spacing: 30
Obstacle Chance: 0.6
Coin Chance: 0.3
Star Chance: 0.05
Shield Chance: 0.02
Quiz Trigger Chance: 0.1
```
### BreakpilotAPI
```
GameObject: BreakpilotAPI
Scripts:
- BreakpilotAPI.cs
Inspector:
Base URL: http://localhost:8000/api/game
Use Offline Cache: true
```
---
## 7. UI Canvas
### Canvas erstellen
1. Rechtsklick in Hierarchy → UI → Canvas
2. Canvas Scaler:
- UI Scale Mode: Scale With Screen Size
- Reference Resolution: 1920 x 1080
### UI Struktur
```
Canvas
├── GameHUD
│ ├── ScorePanel
│ │ ├── ScoreIcon
│ │ └── ScoreText (TextMeshPro)
│ ├── LivesPanel
│ │ ├── Heart1, Heart2, Heart3 (Images)
│ │ └── LivesText (TextMeshPro)
│ └── DistanceText (TextMeshPro)
├── QuickQuizPanel (anfangs deaktiviert)
│ ├── QuestionText (TextMeshPro)
│ ├── AnswerButtons
│ │ ├── Button1, Button2, Button3
│ ├── TimerSlider
│ └── TimerText
├── PauseQuizPanel (anfangs deaktiviert)
│ ├── Background (halbtransparent)
│ ├── QuestionPanel
│ │ ├── QuestionText
│ │ ├── Button1, Button2, Button3, Button4
│ │ └── HintText (optional)
├── GameOverPanel (anfangs deaktiviert)
│ ├── GameOverText
│ ├── FinalScoreText
│ ├── RestartButton
│ └── MainMenuButton
└── PausePanel (anfangs deaktiviert)
├── PauseText
├── ResumeButton
└── MainMenuButton
```
---
## 8. Checkliste
### Prefabs erstellt?
- [ ] PlayerCar.prefab
- [ ] TrackSegment.prefab (mindestens 1)
- [ ] Obstacle_Rock.prefab
- [ ] Coin.prefab
- [ ] Star.prefab
- [ ] Shield.prefab
- [ ] Bridge.prefab (Quiz-Trigger)
### Manager konfiguriert?
- [ ] GameManager - alle UI Referenzen gesetzt
- [ ] QuizManager - alle UI Referenzen gesetzt
- [ ] TrackGenerator - Prefabs zugewiesen
- [ ] ObstacleSpawner - Prefabs zugewiesen
- [ ] BreakpilotAPI - Base URL korrekt
### Tags erstellt?
- [ ] Player
- [ ] Obstacle
- [ ] Coin
- [ ] Star
- [ ] Shield
- [ ] QuizTrigger
### UI Canvas?
- [ ] Canvas Scaler konfiguriert
- [ ] GameHUD erstellt
- [ ] QuickQuizPanel erstellt
- [ ] PauseQuizPanel erstellt
- [ ] GameOverPanel erstellt

213
breakpilot-drive/README.md Normal file
View File

@@ -0,0 +1,213 @@
# Breakpilot Drive - Unity Lernspiel
Ein Endless Runner Lernspiel fuer Kinder der Klassen 2-6 mit Quiz-Integration.
## Uebersicht
- **Video-Version**: Visuelles 3D-Spiel mit 3-Spur-Steuerung
- **Audio-Version**: Barrierefreier Hoerspiel-Modus mit raeumlichem Audio
- **Quiz-Integration**: Hybrid-Modus mit schnellen visuellen Fragen UND Denkpausen
## Schnellstart (Docker)
```bash
# Game-Container starten (mit Backend)
docker-compose --profile game up -d
# Nur das Backend starten (fuer API-Tests)
docker-compose up backend -d
# Game oeffnen
open http://localhost:3001
```
## Unity Projekt erstellen
### Voraussetzungen
- Unity 2022.3 LTS oder neuer
- WebGL Build Support Modul installiert
### Neues Projekt erstellen
```bash
# Unity Hub oeffnen und neues 3D Projekt erstellen
# Projektname: BreakpilotDrive
# Speicherort: /path/to/breakpilot-pwa/breakpilot-drive/Unity/
```
### Empfohlene Projektstruktur
```
Unity/
├── Assets/
│ ├── Scripts/
│ │ ├── Core/
│ │ │ ├── GameManager.cs # Spielzustand, Score
│ │ │ ├── DifficultyManager.cs # Schwierigkeitsanpassung
│ │ │ └── AudioManager.cs # Sound-Verwaltung
│ │ ├── Player/
│ │ │ ├── PlayerController.cs # 3-Spur-Bewegung
│ │ │ └── PlayerInput.cs # Touch/Keyboard Input
│ │ ├── Track/
│ │ │ ├── TrackGenerator.cs # Endlose Strecke
│ │ │ ├── ObstacleSpawner.cs # Hindernisse
│ │ │ └── VisualTrigger.cs # Quiz-Trigger (Bruecke, etc.)
│ │ ├── Quiz/
│ │ │ ├── QuizManager.cs # Quick/Pause-Modus
│ │ │ ├── QuestionDisplay.cs # UI fuer Fragen
│ │ │ └── QuizAudio.cs # TTS fuer Audio-Version
│ │ ├── Network/
│ │ │ ├── BreakpilotAPI.cs # REST API Client
│ │ │ └── OfflineCache.cs # Lokaler Fragen-Cache
│ │ └── UI/
│ │ ├── MainMenu.cs # Startbildschirm
│ │ ├── GameHUD.cs # Score, Leben
│ │ └── QuizOverlay.cs # Frage-Anzeige
│ ├── Prefabs/
│ │ ├── Player/
│ │ │ └── Car.prefab # Spieler-Auto
│ │ ├── Obstacles/
│ │ │ ├── Rock.prefab
│ │ │ └── Barrier.prefab
│ │ ├── Items/
│ │ │ ├── Star.prefab
│ │ │ ├── Coin.prefab
│ │ │ └── Shield.prefab
│ │ └── VisualTriggers/
│ │ ├── Bridge.prefab # Loest Quick-Quiz aus
│ │ ├── House.prefab
│ │ └── Tree.prefab
│ ├── Audio/
│ │ ├── Music/
│ │ │ └── GameLoop.mp3
│ │ ├── SFX/
│ │ │ ├── Coin.wav
│ │ │ ├── Crash.wav
│ │ │ └── Correct.wav
│ │ └── Voice/
│ │ └── (TTS-generiert)
│ ├── Scenes/
│ │ ├── MainMenu.unity
│ │ ├── Game_Video.unity
│ │ └── Game_Audio.unity
│ └── WebGLTemplates/
│ └── Breakpilot/
│ └── index.html # Custom WebGL Template
├── Packages/
│ └── manifest.json
└── ProjectSettings/
└── ProjectSettings.asset
```
## API-Endpunkte
Das Backend stellt folgende Endpunkte bereit:
### Lernniveau
```
GET /api/game/learning-level/{user_id}
→ { overall_level: 1-5, math_level, german_level, english_level }
```
### Schwierigkeit
```
GET /api/game/difficulty/{level}
→ { lane_speed, obstacle_frequency, question_complexity, answer_time }
```
### Quiz-Fragen
```
GET /api/game/quiz/questions?difficulty=3&count=10&mode=quick
→ [{ id, question_text, options, correct_index, visual_trigger, time_limit_seconds }]
GET /api/game/quiz/questions?difficulty=3&count=5&mode=pause
→ [{ id, question_text, options, correct_index }]
```
### Visuelle Trigger
```
GET /api/game/quiz/visual-triggers
→ [{ trigger: "bridge", question_count: 2, difficulties: [1,2] }]
```
### Session speichern
```
POST /api/game/session
{ user_id, game_mode, duration_seconds, score, questions_answered, questions_correct }
→ { session_id, status, new_level? }
```
## WebGL Build erstellen
1. Unity oeffnen → File → Build Settings
2. Platform: WebGL auswaehlen
3. Player Settings:
- Compression Format: Gzip
- Decompression Fallback: Aktiviert
- WebGL Template: Breakpilot (custom)
4. Build → Zielordner: `breakpilot-drive/Build/`
Nach dem Build:
```bash
# Docker Container neu bauen
docker-compose --profile game build breakpilot-drive
# Container starten
docker-compose --profile game up -d breakpilot-drive
```
## Quiz-Modus Implementierung
### Quick Mode (waehrend der Fahrt)
```csharp
// Wenn Spieler sich einem VisualTrigger naehert
public class VisualTrigger : MonoBehaviour
{
public string triggerType; // "bridge", "tree", etc.
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
// Frage anzeigen, Spiel laeuft weiter
QuizManager.Instance.ShowQuickQuestion(triggerType);
}
}
}
```
### Pause Mode (Spiel pausiert)
```csharp
// Alle X Meter/Punkte eine Denkaufgabe
public class QuizManager : MonoBehaviour
{
public void ShowPauseQuestion()
{
Time.timeScale = 0; // Spiel pausieren
// Komplexe Frage anzeigen
// Nach Antwort: Time.timeScale = 1;
}
}
```
## Entwicklungs-Workflow
1. **Lokal entwickeln**: Unity Editor mit Play-Modus
2. **API testen**: `docker-compose up backend -d`
3. **WebGL testen**: Build erstellen, Container starten
4. **Commit**: Git-Aenderungen committen (ohne Build-Artefakte)
## Bekannte Einschraenkungen
- Unity WebGL unterstuetzt kein natives Audio-Recording (fuer Sprachsteuerung)
- Mobile Browser haben unterschiedliche Touch-Implementierungen
- WASM-Dateien koennen gross werden (>50MB) - Kompression wichtig
## Ressourcen
- [Unity WebGL Doku](https://docs.unity3d.com/Manual/webgl.html)
- [UnityWebRequest](https://docs.unity3d.com/ScriptReference/Networking.UnityWebRequest.html)
- [Breakpilot Drive Plan](../docs/breakpilot-drive-plan.md)

View File

@@ -0,0 +1,2 @@
# Placeholder fuer Unity WebGL Template Data
# Dieser Ordner enthaelt Icons, Loading Bar, etc.

View File

@@ -0,0 +1,137 @@
# Third Party Notices - Breakpilot Drive
Dieses Projekt verwendet folgende Open-Source-Komponenten und Assets.
---
## Assets
### Kenney Game Assets - Car Kit
- **Quelle**: https://kenney.nl/assets/car-kit
- **Autor**: Kenney (www.kenney.nl)
- **Lizenz**: CC0 1.0 Universal (Public Domain Dedication)
- **Verwendung**: Fahrzeug-Modelle, Strassenelemente
```
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
Die Person, die ein Werk mit dieser Deed verknuepft hat, hat dieses Werk
der Oeffentlichkeit gewidmet, indem sie weltweit auf alle Rechte an dem
Werk unter dem Urheberrecht verzichtet hat, einschliesslich aller verwandten
und benachbarten Rechte, soweit dies gesetzlich zulaessig ist.
Sie koennen das Werk kopieren, veraendern, verbreiten und auffuehren,
selbst zu kommerziellen Zwecken, ohne um Erlaubnis zu fragen.
https://creativecommons.org/publicdomain/zero/1.0/
```
---
### Kenney Game Assets - Nature Kit
- **Quelle**: https://kenney.nl/assets/nature-kit
- **Autor**: Kenney (www.kenney.nl)
- **Lizenz**: CC0 1.0 Universal (Public Domain Dedication)
- **Verwendung**: Baeume, Steine, Natur-Elemente (Hindernisse, Quiz-Trigger)
```
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
```
---
## Schriftarten
### Verwendete Schriftart (zu aktualisieren)
Falls Sie eine Google Font verwenden:
- **Quelle**: https://fonts.google.com/
- **Lizenz**: Open Font License (OFL)
- **Verwendung**: UI-Text
---
## Audio (zu aktualisieren)
Falls Sie Audio von Freesound.org verwenden:
### [Sound-Name]
- **Quelle**: https://freesound.org/people/[username]/sounds/[id]/
- **Autor**: [Username]
- **Lizenz**: CC0 oder CC-BY
- **Verwendung**: [Beschreibung]
---
## Code
### Breakpilot Drive Scripts
- **Quelle**: Eigenentwicklung
- **Lizenz**: MIT
- **Copyright**: (c) 2024 Breakpilot
```
MIT License
Copyright (c) 2024 Breakpilot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
## Unity Packages
### TextMeshPro
- **Quelle**: Unity Technologies
- **Lizenz**: Unity Companion License
- **Verwendung**: Text-Rendering fuer UI
---
## Hinweise
### Kenney.nl Assets
Alle Kenney Assets sind unter CC0 lizenziert und koennen:
- Kommerziell verwendet werden
- Modifiziert werden
- Ohne Attribution verwendet werden (Attribution ist aber erwuenscht)
Empfohlene Attribution (optional aber nett):
> Assets by Kenney (www.kenney.nl)
### Lizenz-Compliance
Bei Hinzufuegen neuer Assets:
1. Lizenz pruefen (muss kommerziell nutzbar sein)
2. Diese Datei aktualisieren
3. NC (Non-Commercial) und ND (No-Derivatives) vermeiden
---
*Letzte Aktualisierung: Januar 2024*

View File

@@ -0,0 +1,278 @@
# Breakpilot Drive - Unity Projekt Setup
## Schritt-fuer-Schritt Anleitung
Diese Anleitung fuehrt Sie durch die Einrichtung des Unity-Projekts nach der Installation.
---
## 1. Neues Projekt erstellen
1. **Unity Hub** oeffnen
2. **"Projects"** Tab waehlen
3. **"New project"** klicken
4. Einstellungen:
- Template: **3D (Built-in Render Pipeline)**
- Project name: **BreakpilotDrive**
- Location: `/pfad/zu/breakpilot-pwa/breakpilot-drive/Unity/`
5. **"Create project"** klicken
---
## 2. Ordnerstruktur erstellen
Nach dem Projekt-Start in Unity:
### 2.1 Im Project-Fenster (unten)
Rechtsklick → Create → Folder fuer jeden Ordner:
```
Assets/
├── Scripts/
│ ├── Core/
│ ├── Player/
│ ├── Track/
│ ├── Quiz/
│ ├── Network/
│ └── UI/
├── Prefabs/
│ ├── Player/
│ ├── Obstacles/
│ ├── Items/
│ └── VisualTriggers/
├── Audio/
│ ├── Music/
│ ├── SFX/
│ └── Voice/
├── Scenes/
├── Materials/
├── Plugins/
│ └── WebGL/
├── ThirdParty/
│ └── Kenney/
└── WebGLTemplates/
└── Breakpilot/
```
---
## 3. Scripts importieren
### 3.1 Im Finder/Explorer
1. Oeffnen Sie: `breakpilot-pwa/breakpilot-drive/UnityScripts/`
2. Kopieren Sie die Dateien in die entsprechenden Unity-Ordner:
| Quelle | Ziel |
|--------|------|
| `UnityScripts/Core/*.cs` | `Assets/Scripts/Core/` |
| `UnityScripts/Player/*.cs` | `Assets/Scripts/Player/` |
| `UnityScripts/Track/*.cs` | `Assets/Scripts/Track/` |
| `UnityScripts/Quiz/*.cs` | `Assets/Scripts/Quiz/` |
| `UnityScripts/UI/*.cs` | `Assets/Scripts/UI/` |
| `UnityScripts/BreakpilotAPI.cs` | `Assets/Scripts/Network/` |
| `UnityScripts/QuizManager.cs` | `Assets/Scripts/Quiz/` |
| `UnityScripts/Plugins/*.jslib` | `Assets/Plugins/WebGL/` |
| `UnityScripts/WebGLTemplate/Breakpilot/*` | `Assets/WebGLTemplates/Breakpilot/` |
### 3.2 In Unity
Nach dem Kopieren:
1. Zurueck zu Unity wechseln
2. Unity importiert die Dateien automatisch
3. Falls Fehler erscheinen: Console pruefen (Window → General → Console)
---
## 4. Kenney Assets importieren
### 4.1 Car Kit
1. Finden Sie die heruntergeladene ZIP-Datei (`kenney_car-kit.zip`)
2. Entpacken Sie sie
3. In Unity: **Assets → Import Package → Custom Package**
4. Oder: Ziehen Sie die entpackten Dateien in `Assets/ThirdParty/Kenney/CarKit/`
### 4.2 Nature Kit
1. Finden Sie `kenney_nature-kit.zip`
2. Entpacken Sie sie
3. Ziehen Sie die Dateien in `Assets/ThirdParty/Kenney/NatureKit/`
---
## 5. TextMeshPro installieren
1. **Window → TextMeshPro → Import TMP Essential Resources**
2. Im Dialog: **"Import"** klicken
3. Warten bis der Import abgeschlossen ist
---
## 6. Szenen erstellen
### 6.1 MainMenu Szene
1. **File → New Scene**
2. **File → Save As**`Assets/Scenes/MainMenu.unity`
### 6.2 Game_Video Szene
1. **File → New Scene**
2. **File → Save As**`Assets/Scenes/Game_Video.unity`
### 6.3 Game_Audio Szene (optional fuer spaeter)
1. **File → New Scene**
2. **File → Save As**`Assets/Scenes/Game_Audio.unity`
---
## 7. Manager-Objekte erstellen
### 7.1 In Game_Video Szene
1. Oeffnen Sie `Game_Video.unity`
2. Erstellen Sie leere GameObjects (Rechtsklick in Hierarchy → Create Empty):
| Name | Scripts hinzufuegen |
|------|---------------------|
| **GameManager** | GameManager.cs |
| **DifficultyManager** | DifficultyManager.cs |
| **AudioManager** | AudioManager.cs |
| **BreakpilotAPI** | BreakpilotAPI.cs |
| **QuizManager** | QuizManager.cs |
| **TrackGenerator** | TrackGenerator.cs |
| **ObstacleSpawner** | ObstacleSpawner.cs |
### 7.2 Script hinzufuegen
1. GameObject auswaehlen
2. Im Inspector: **Add Component**
3. Script-Namen eingeben (z.B. "GameManager")
4. Script auswaehlen
---
## 8. Spieler erstellen
### 8.1 Auto-Prefab
1. In `Assets/ThirdParty/Kenney/CarKit/` ein Auto-Modell finden
2. In die Szene ziehen
3. Komponenten hinzufuegen:
- **Rigidbody** (Add Component → Physics → Rigidbody)
- **Box Collider** (Add Component → Physics → Box Collider)
- **PlayerController.cs** (Add Component → Scripts → Player)
4. Tag setzen: Im Inspector → Tag → **"Player"** (falls nicht vorhanden: Add Tag)
5. Als Prefab speichern: Aus Hierarchy in `Assets/Prefabs/Player/` ziehen
---
## 9. Tags erstellen
1. **Edit → Project Settings → Tags and Layers**
2. Tags hinzufuegen:
- `Player`
- `Obstacle`
- `Coin`
- `Star`
- `Shield`
- `QuizTrigger`
---
## 10. Build Settings
### 10.1 WebGL Platform
1. **File → Build Settings**
2. **WebGL** auswaehlen
3. **"Switch Platform"** klicken (kann einige Minuten dauern)
### 10.2 Szenen hinzufuegen
1. Im Build Settings Fenster
2. **"Add Open Scenes"** klicken fuer jede Szene
3. Reihenfolge: MainMenu (Index 0), Game_Video (Index 1)
### 10.3 Player Settings
1. Im Build Settings: **"Player Settings"** klicken
2. Einstellungen:
```
Company Name: Breakpilot
Product Name: Breakpilot Drive
Version: 0.1.0
Resolution and Presentation:
WebGL Template: Breakpilot (falls verfuegbar)
Run in Background: true
Publishing Settings:
Compression Format: Gzip
Decompression Fallback: true
Data Caching: true
```
---
## 11. Erster Test
### 11.1 Im Editor
1. Game_Video Szene oeffnen
2. **Play-Button** druecken
3. Console auf Fehler pruefen
### 11.2 Haeufige Fehler
| Fehler | Loesung |
|--------|---------|
| "Script can't be loaded" | Script-Datei pruefen, Namespace korrekt? |
| "Missing Reference" | Inspector pruefen, Referenzen zuweisen |
| "Tag not found" | Tags in Project Settings erstellen |
| "TMP not found" | TextMeshPro importieren |
---
## 12. WebGL Build
Erst nach erfolgreichem Editor-Test:
1. **File → Build Settings**
2. **"Build"** klicken
3. Zielordner: `breakpilot-pwa/breakpilot-drive/Build/`
4. Warten (kann 5-10 Minuten dauern)
Nach dem Build:
```bash
cd breakpilot-pwa
docker-compose --profile game build breakpilot-drive
docker-compose --profile game up -d
open http://localhost:3001
```
---
## Naechste Schritte
Nach dem Setup:
1. [ ] Strecken-Prefabs erstellen (aus Kenney Assets)
2. [ ] Hindernis-Prefabs erstellen
3. [ ] Quiz-Trigger-Prefabs erstellen
4. [ ] UI Canvas einrichten
5. [ ] Audio-Clips zuweisen
6. [ ] API-URL konfigurieren
---
## Hilfe
Bei Problemen:
- Unity Console pruefen (Window → General → Console)
- Dokumentation lesen: `docs/breakpilot-drive/`
- API testen: `curl http://localhost:8000/api/game/health`

View File

@@ -0,0 +1,599 @@
// ==============================================
// BreakpilotAPI.cs - Unity API Client
// ==============================================
// Kopiere diese Datei nach Assets/Scripts/Network/
// um mit dem Breakpilot Backend zu kommunizieren.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
namespace BreakpilotDrive
{
[Serializable]
public class LearningLevel
{
public string user_id;
public int overall_level;
public float math_level;
public float german_level;
public float english_level;
}
[Serializable]
public class GameDifficulty
{
public float lane_speed;
public float obstacle_frequency;
public float power_up_chance;
public int question_complexity;
public int answer_time;
public bool hints_enabled;
public float speech_speed;
}
[Serializable]
public class QuizQuestion
{
public string id;
public string question_text;
public string audio_url;
public string[] options;
public int correct_index;
public int difficulty;
public string subject;
public int grade_level;
public string quiz_mode; // "quick" oder "pause"
public string visual_trigger; // z.B. "bridge"
public float time_limit_seconds;
}
[Serializable]
public class QuizQuestionList
{
public QuizQuestion[] questions;
}
[Serializable]
public class GameSession
{
public string user_id;
public string game_mode; // "video" oder "audio"
public int duration_seconds;
public float distance_traveled;
public int score;
public int questions_answered;
public int questions_correct;
public int difficulty_level;
}
[Serializable]
public class SessionResponse
{
public string session_id;
public string status;
public int? new_level;
}
[Serializable]
public class VisualTrigger
{
public string trigger;
public int question_count;
public int[] difficulties;
public string[] subjects;
}
[Serializable]
public class ProgressData
{
public string date;
public int sessions;
public int total_score;
public int questions;
public int correct;
public float accuracy;
public float avg_difficulty;
}
[Serializable]
public class ProgressResponse
{
public string user_id;
public int days;
public int data_points;
public ProgressData[] progress;
}
[Serializable]
public class LeaderboardEntry
{
public int rank;
public string user_id;
public int total_score;
public string display_name;
}
public class BreakpilotAPI : MonoBehaviour
{
// Singleton Pattern
public static BreakpilotAPI Instance { get; private set; }
[Header("API Konfiguration")]
[SerializeField] private string baseUrl = "http://localhost:8000/api/game";
[SerializeField] private bool useOfflineCache = true;
// Cached Data
private LearningLevel cachedLevel;
private GameDifficulty cachedDifficulty;
private List<QuizQuestion> cachedQuestions = new List<QuizQuestion>();
private List<VisualTrigger> cachedTriggers = new List<VisualTrigger>();
// API URL property (for other scripts)
public string BaseUrl => baseUrl;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
// ==============================================
// Helper: Add Auth Header to Request
// ==============================================
private void AddAuthHeader(UnityWebRequest request)
{
if (AuthManager.Instance != null)
{
string authHeader = AuthManager.Instance.GetAuthHeader();
if (!string.IsNullOrEmpty(authHeader))
{
request.SetRequestHeader("Authorization", authHeader);
}
}
}
// Get current user ID (from auth or fallback)
public string GetCurrentUserId()
{
if (AuthManager.Instance != null && AuthManager.Instance.IsAuthenticated)
{
return AuthManager.Instance.UserId;
}
return "anonymous";
}
// ==============================================
// Lernniveau abrufen
// ==============================================
public IEnumerator GetLearningLevel(string userId, Action<LearningLevel> onSuccess, Action<string> onError = null)
{
string url = $"{baseUrl}/learning-level/{userId}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
LearningLevel level = JsonUtility.FromJson<LearningLevel>(request.downloadHandler.text);
cachedLevel = level;
onSuccess?.Invoke(level);
}
else
{
Debug.LogWarning($"API Fehler: {request.error}");
// Offline-Fallback
if (useOfflineCache && cachedLevel != null)
{
onSuccess?.Invoke(cachedLevel);
}
else
{
// Standard-Level
onSuccess?.Invoke(new LearningLevel { user_id = userId, overall_level = 3 });
}
onError?.Invoke(request.error);
}
}
}
// ==============================================
// Schwierigkeit abrufen
// ==============================================
public IEnumerator GetDifficulty(int level, Action<GameDifficulty> onSuccess, Action<string> onError = null)
{
string url = $"{baseUrl}/difficulty/{level}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
GameDifficulty difficulty = JsonUtility.FromJson<GameDifficulty>(request.downloadHandler.text);
cachedDifficulty = difficulty;
onSuccess?.Invoke(difficulty);
}
else
{
Debug.LogWarning($"API Fehler: {request.error}");
// Fallback: Default-Schwierigkeit
if (cachedDifficulty != null)
{
onSuccess?.Invoke(cachedDifficulty);
}
else
{
onSuccess?.Invoke(GetDefaultDifficulty(level));
}
onError?.Invoke(request.error);
}
}
}
// ==============================================
// Quiz-Fragen abrufen
// ==============================================
public IEnumerator GetQuizQuestions(
int difficulty,
int count,
string mode = null, // "quick", "pause", oder null fuer beide
string subject = null,
Action<QuizQuestion[]> onSuccess = null,
Action<string> onError = null)
{
string url = $"{baseUrl}/quiz/questions?difficulty={difficulty}&count={count}";
if (!string.IsNullOrEmpty(mode)) url += $"&mode={mode}";
if (!string.IsNullOrEmpty(subject)) url += $"&subject={subject}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
// Unity's JsonUtility braucht Wrapper fuer Arrays
string wrappedJson = "{\"questions\":" + request.downloadHandler.text + "}";
QuizQuestionList list = JsonUtility.FromJson<QuizQuestionList>(wrappedJson);
// Cache updaten
if (list.questions != null)
{
cachedQuestions.AddRange(list.questions);
}
onSuccess?.Invoke(list.questions);
}
else
{
Debug.LogWarning($"API Fehler: {request.error}");
// Offline-Fallback
if (useOfflineCache && cachedQuestions.Count > 0)
{
var filtered = cachedQuestions.FindAll(q =>
Math.Abs(q.difficulty - difficulty) <= 1 &&
(string.IsNullOrEmpty(mode) || q.quiz_mode == mode)
);
onSuccess?.Invoke(filtered.GetRange(0, Math.Min(count, filtered.Count)).ToArray());
}
onError?.Invoke(request.error);
}
}
}
// ==============================================
// Visuelle Trigger abrufen
// ==============================================
public IEnumerator GetVisualTriggers(Action<VisualTrigger[]> onSuccess, Action<string> onError = null)
{
string url = $"{baseUrl}/quiz/visual-triggers";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
// Parse Array
string wrappedJson = "{\"triggers\":" + request.downloadHandler.text + "}";
VisualTriggersWrapper wrapper = JsonUtility.FromJson<VisualTriggersWrapper>(wrappedJson);
cachedTriggers = new List<VisualTrigger>(wrapper.triggers);
onSuccess?.Invoke(wrapper.triggers);
}
else
{
Debug.LogWarning($"API Fehler: {request.error}");
onError?.Invoke(request.error);
}
}
}
[Serializable]
private class VisualTriggersWrapper
{
public VisualTrigger[] triggers;
}
// ==============================================
// Spielsession speichern
// ==============================================
public IEnumerator SaveGameSession(GameSession session, Action<SessionResponse> onSuccess = null, Action<string> onError = null)
{
string url = $"{baseUrl}/session";
string json = JsonUtility.ToJson(session);
using (UnityWebRequest request = new UnityWebRequest(url, "POST"))
{
byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
SessionResponse response = JsonUtility.FromJson<SessionResponse>(request.downloadHandler.text);
onSuccess?.Invoke(response);
// Bei Level-Aenderung cachedLevel updaten
if (response.new_level.HasValue && cachedLevel != null)
{
cachedLevel.overall_level = response.new_level.Value;
}
}
else
{
Debug.LogWarning($"Session speichern fehlgeschlagen: {request.error}");
// Offline: Spaeter synchronisieren
if (useOfflineCache)
{
QueueOfflineSession(session);
}
onError?.Invoke(request.error);
}
}
}
// ==============================================
// Offline-Handling
// ==============================================
private List<GameSession> offlineSessions = new List<GameSession>();
private void QueueOfflineSession(GameSession session)
{
offlineSessions.Add(session);
// Spaeter: PlayerPrefs oder lokale DB nutzen
Debug.Log($"Session zur Offline-Queue hinzugefuegt. Queue-Groesse: {offlineSessions.Count}");
}
public IEnumerator SyncOfflineSessions()
{
var sessionsToSync = new List<GameSession>(offlineSessions);
offlineSessions.Clear();
foreach (var session in sessionsToSync)
{
yield return SaveGameSession(session,
onSuccess: (response) => Debug.Log($"Offline-Session synchronisiert: {response.session_id}"),
onError: (error) => offlineSessions.Add(session) // Zurueck in Queue
);
}
}
// ==============================================
// Helper
// ==============================================
private GameDifficulty GetDefaultDifficulty(int level)
{
// Fallback-Werte wenn API nicht erreichbar
return new GameDifficulty
{
lane_speed = 3f + level,
obstacle_frequency = 0.3f + (level * 0.1f),
power_up_chance = 0.4f - (level * 0.05f),
question_complexity = level,
answer_time = 15 - (level * 2),
hints_enabled = level <= 3,
speech_speed = 0.8f + (level * 0.1f)
};
}
// ==============================================
// Frage fuer visuellen Trigger holen
// ==============================================
public QuizQuestion GetQuestionForTrigger(string triggerType, int difficulty)
{
var matching = cachedQuestions.FindAll(q =>
q.quiz_mode == "quick" &&
q.visual_trigger == triggerType &&
Math.Abs(q.difficulty - difficulty) <= 1
);
if (matching.Count > 0)
{
return matching[UnityEngine.Random.Range(0, matching.Count)];
}
return null;
}
// ==============================================
// Cache-Zugriff
// ==============================================
public LearningLevel GetCachedLevel()
{
return cachedLevel;
}
public List<QuizQuestion> GetCachedQuestions()
{
return cachedQuestions;
}
public GameDifficulty GetCachedDifficulty()
{
return cachedDifficulty;
}
public int GetCachedQuestionsCount()
{
return cachedQuestions.Count;
}
public void ClearQuestionCache()
{
cachedQuestions.Clear();
}
// ==============================================
// Progress API (Phase 5)
// ==============================================
public IEnumerator GetProgress(
string userId,
int days,
Action<ProgressResponse> onSuccess,
Action<string> onError = null)
{
string url = $"{baseUrl}/progress/{userId}?days={days}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
AddAuthHeader(request);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var response = JsonUtility.FromJson<ProgressResponse>(request.downloadHandler.text);
onSuccess?.Invoke(response);
}
else
{
Debug.LogWarning($"Progress API error: {request.error}");
onError?.Invoke(request.error);
}
}
}
// ==============================================
// Leaderboard API (Phase 5)
// ==============================================
public IEnumerator GetLeaderboard(
string timeframe,
int limit,
bool anonymize,
Action<LeaderboardEntry[]> onSuccess,
Action<string> onError = null)
{
string url = $"{baseUrl}/leaderboard/display?timeframe={timeframe}&limit={limit}&anonymize={anonymize.ToString().ToLower()}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string wrappedJson = "{\"entries\":" + request.downloadHandler.text + "}";
var wrapper = JsonUtility.FromJson<LeaderboardWrapper>(wrappedJson);
onSuccess?.Invoke(wrapper.entries);
}
else
{
Debug.LogWarning($"Leaderboard API error: {request.error}");
onError?.Invoke(request.error);
}
}
}
[Serializable]
private class LeaderboardWrapper
{
public LeaderboardEntry[] entries;
}
// ==============================================
// Stats API
// ==============================================
public IEnumerator GetUserStats(
string userId,
Action<UserStats> onSuccess,
Action<string> onError = null)
{
string url = $"{baseUrl}/stats/{userId}";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
AddAuthHeader(request);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var stats = JsonUtility.FromJson<UserStats>(request.downloadHandler.text);
onSuccess?.Invoke(stats);
}
else
{
Debug.LogWarning($"Stats API error: {request.error}");
onError?.Invoke(request.error);
}
}
}
[Serializable]
public class UserStats
{
public string user_id;
public int overall_level;
public float math_level;
public float german_level;
public float english_level;
public int total_play_time_minutes;
public int total_sessions;
public int questions_answered;
public int questions_correct;
public float accuracy;
}
// ==============================================
// Health Check
// ==============================================
public IEnumerator HealthCheck(Action<bool, string> onComplete)
{
string url = $"{baseUrl}/health";
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
request.timeout = 5;
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
onComplete?.Invoke(true, request.downloadHandler.text);
}
else
{
onComplete?.Invoke(false, request.error);
}
}
}
}
}

View File

@@ -0,0 +1,348 @@
// ==============================================
// AchievementManager.cs - Achievement System
// ==============================================
// Handles achievement display and notifications.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace BreakpilotDrive
{
[Serializable]
public class Achievement
{
public string id;
public string name;
public string description;
public string icon;
public string category;
public int threshold;
public int progress;
public bool unlocked;
}
[Serializable]
public class AchievementResponse
{
public string user_id;
public int total;
public int unlocked_count;
public Achievement[] achievements;
}
[Serializable]
private class AchievementArrayWrapper
{
public Achievement[] achievements;
}
public class AchievementManager : MonoBehaviour
{
public static AchievementManager Instance { get; private set; }
[Header("UI References")]
[SerializeField] private GameObject achievementPopupPrefab;
[SerializeField] private Transform popupContainer;
[SerializeField] private float popupDuration = 3f;
[Header("Audio")]
[SerializeField] private AudioClip unlockSound;
// Events
public UnityEvent<Achievement> OnAchievementUnlocked;
// Cached data
private List<Achievement> achievements = new List<Achievement>();
private HashSet<string> previouslyUnlocked = new HashSet<string>();
private Queue<Achievement> popupQueue = new Queue<Achievement>();
private bool isShowingPopup = false;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
// ==============================================
// Load Achievements from API
// ==============================================
public IEnumerator LoadAchievements(string userId, Action<AchievementResponse> onComplete = null)
{
string url = $"{GetBaseUrl()}/achievements/{userId}";
using (var request = UnityEngine.Networking.UnityWebRequest.Get(url))
{
// Add auth header if available
string authHeader = AuthManager.Instance?.GetAuthHeader();
if (!string.IsNullOrEmpty(authHeader))
{
request.SetRequestHeader("Authorization", authHeader);
}
yield return request.SendWebRequest();
if (request.result == UnityEngine.Networking.UnityWebRequest.Result.Success)
{
var response = JsonUtility.FromJson<AchievementResponse>(request.downloadHandler.text);
// Store previously unlocked for comparison
previouslyUnlocked.Clear();
foreach (var a in achievements)
{
if (a.unlocked)
previouslyUnlocked.Add(a.id);
}
// Update achievements
achievements.Clear();
if (response.achievements != null)
{
achievements.AddRange(response.achievements);
}
onComplete?.Invoke(response);
}
else
{
Debug.LogWarning($"Failed to load achievements: {request.error}");
onComplete?.Invoke(null);
}
}
}
// ==============================================
// Check for New Achievements
// ==============================================
public void CheckForNewUnlocks()
{
foreach (var achievement in achievements)
{
if (achievement.unlocked && !previouslyUnlocked.Contains(achievement.id))
{
// New unlock!
ShowAchievementPopup(achievement);
OnAchievementUnlocked?.Invoke(achievement);
}
}
}
// Call after each game session to check for new achievements
public IEnumerator RefreshAndCheckAchievements(string userId)
{
yield return LoadAchievements(userId, (response) =>
{
if (response != null)
{
CheckForNewUnlocks();
}
});
}
// ==============================================
// Achievement Popup
// ==============================================
public void ShowAchievementPopup(Achievement achievement)
{
popupQueue.Enqueue(achievement);
if (!isShowingPopup)
{
StartCoroutine(ProcessPopupQueue());
}
}
private IEnumerator ProcessPopupQueue()
{
isShowingPopup = true;
while (popupQueue.Count > 0)
{
var achievement = popupQueue.Dequeue();
yield return ShowSinglePopup(achievement);
yield return new WaitForSeconds(0.3f); // Gap between popups
}
isShowingPopup = false;
}
private IEnumerator ShowSinglePopup(Achievement achievement)
{
Debug.Log($"[Achievement] Unlocked: {achievement.name}");
// Play sound
if (unlockSound != null && AudioManager.Instance != null)
{
AudioManager.Instance.PlaySFX(unlockSound);
}
// Create popup UI
if (achievementPopupPrefab != null && popupContainer != null)
{
var popup = Instantiate(achievementPopupPrefab, popupContainer);
var popupScript = popup.GetComponent<AchievementPopup>();
if (popupScript != null)
{
popupScript.Setup(achievement);
}
yield return new WaitForSeconds(popupDuration);
if (popup != null)
{
Destroy(popup);
}
}
else
{
// Fallback: just wait
yield return new WaitForSeconds(popupDuration);
}
}
// ==============================================
// Public API
// ==============================================
public List<Achievement> GetAllAchievements()
{
return new List<Achievement>(achievements);
}
public List<Achievement> GetUnlockedAchievements()
{
return achievements.FindAll(a => a.unlocked);
}
public List<Achievement> GetLockedAchievements()
{
return achievements.FindAll(a => !a.unlocked);
}
public List<Achievement> GetAchievementsByCategory(string category)
{
return achievements.FindAll(a => a.category == category);
}
public int GetUnlockedCount()
{
int count = 0;
foreach (var a in achievements)
{
if (a.unlocked) count++;
}
return count;
}
public int GetTotalCount()
{
return achievements.Count;
}
public float GetCompletionPercentage()
{
if (achievements.Count == 0) return 0f;
return (float)GetUnlockedCount() / achievements.Count * 100f;
}
public Achievement GetAchievement(string id)
{
return achievements.Find(a => a.id == id);
}
// ==============================================
// Icon Mapping
// ==============================================
public Sprite GetIconSprite(string iconName)
{
// Load from Resources/Icons/Achievements/
string path = $"Icons/Achievements/{iconName}";
var sprite = Resources.Load<Sprite>(path);
if (sprite == null)
{
// Fallback icon
sprite = Resources.Load<Sprite>("Icons/Achievements/default");
}
return sprite;
}
// ==============================================
// Helpers
// ==============================================
private string GetBaseUrl()
{
if (BreakpilotAPI.Instance != null)
{
return "http://localhost:8000/api/game"; // TODO: Get from BreakpilotAPI
}
return "http://localhost:8000/api/game";
}
}
// ==============================================
// Achievement Popup Component
// ==============================================
public class AchievementPopup : MonoBehaviour
{
[SerializeField] private UnityEngine.UI.Text titleText;
[SerializeField] private UnityEngine.UI.Text descriptionText;
[SerializeField] private UnityEngine.UI.Image iconImage;
public void Setup(Achievement achievement)
{
if (titleText != null)
titleText.text = achievement.name;
if (descriptionText != null)
descriptionText.text = achievement.description;
if (iconImage != null)
{
var sprite = AchievementManager.Instance?.GetIconSprite(achievement.icon);
if (sprite != null)
iconImage.sprite = sprite;
}
// Animate in
StartCoroutine(AnimateIn());
}
private IEnumerator AnimateIn()
{
var canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup != null)
{
canvasGroup.alpha = 0f;
float duration = 0.3f;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
canvasGroup.alpha = Mathf.Lerp(0f, 1f, elapsed / duration);
yield return null;
}
canvasGroup.alpha = 1f;
}
}
}
}

View File

@@ -0,0 +1,290 @@
// ==============================================
// AudioManager.cs - Audio-Verwaltung
// ==============================================
// Verwaltet Musik, Sound-Effekte und
// Text-to-Speech fuer das Spiel.
using System.Collections.Generic;
using UnityEngine;
namespace BreakpilotDrive
{
[System.Serializable]
public class SoundEffect
{
public string name;
public AudioClip clip;
[Range(0f, 1f)]
public float volume = 1f;
[Range(0.5f, 1.5f)]
public float pitch = 1f;
public bool loop = false;
}
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
[Header("Audio Sources")]
[SerializeField] private AudioSource musicSource;
[SerializeField] private AudioSource sfxSource;
[SerializeField] private AudioSource voiceSource;
[Header("Musik")]
[SerializeField] private AudioClip menuMusic;
[SerializeField] private AudioClip gameMusic;
[Header("Sound-Effekte")]
[SerializeField] private SoundEffect[] soundEffects;
[Header("Lautstaerke")]
[Range(0f, 1f)]
[SerializeField] private float masterVolume = 1f;
[Range(0f, 1f)]
[SerializeField] private float musicVolume = 0.5f;
[Range(0f, 1f)]
[SerializeField] private float sfxVolume = 1f;
[Range(0f, 1f)]
[SerializeField] private float voiceVolume = 1f;
// Sound-Dictionary fuer schnellen Zugriff
private Dictionary<string, SoundEffect> soundDict = new Dictionary<string, SoundEffect>();
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
InitializeSounds();
LoadVolumeSettings();
}
else
{
Destroy(gameObject);
}
}
private void InitializeSounds()
{
soundDict.Clear();
foreach (var sound in soundEffects)
{
if (!string.IsNullOrEmpty(sound.name) && sound.clip != null)
{
soundDict[sound.name.ToLower()] = sound;
}
}
}
// ==============================================
// Musik
// ==============================================
public void PlayMenuMusic()
{
PlayMusic(menuMusic);
}
public void PlayGameMusic()
{
PlayMusic(gameMusic);
}
public void PlayMusic(AudioClip clip)
{
if (musicSource == null || clip == null) return;
if (musicSource.clip == clip && musicSource.isPlaying)
return;
musicSource.clip = clip;
musicSource.volume = musicVolume * masterVolume;
musicSource.loop = true;
musicSource.Play();
}
public void StopMusic()
{
if (musicSource != null)
{
musicSource.Stop();
}
}
public void PauseMusic()
{
if (musicSource != null)
{
musicSource.Pause();
}
}
public void ResumeMusic()
{
if (musicSource != null)
{
musicSource.UnPause();
}
}
// ==============================================
// Sound-Effekte
// ==============================================
public void PlaySound(string soundName)
{
if (sfxSource == null) return;
string key = soundName.ToLower();
if (soundDict.TryGetValue(key, out SoundEffect sound))
{
sfxSource.pitch = sound.pitch;
sfxSource.PlayOneShot(sound.clip, sound.volume * sfxVolume * masterVolume);
}
else
{
Debug.LogWarning($"Sound '{soundName}' nicht gefunden!");
}
}
public void PlaySound(AudioClip clip, float volume = 1f)
{
if (sfxSource == null || clip == null) return;
sfxSource.PlayOneShot(clip, volume * sfxVolume * masterVolume);
}
// Vordefinierte Sound-Methoden
public void PlayCoinSound() => PlaySound("coin");
public void PlayCrashSound() => PlaySound("crash");
public void PlayCorrectSound() => PlaySound("correct");
public void PlayWrongSound() => PlaySound("wrong");
public void PlayButtonSound() => PlaySound("button");
// ==============================================
// Text-to-Speech (WebGL)
// ==============================================
public void Speak(string text)
{
if (string.IsNullOrEmpty(text)) return;
#if UNITY_WEBGL && !UNITY_EDITOR
SpeakWebGL(text);
#else
Debug.Log($"[TTS] {text}");
#endif
}
public void SpeakQuestion(QuizQuestion question)
{
if (question == null) return;
Speak(question.question_text);
// Optionen vorlesen (mit Verzoegerung)
for (int i = 0; i < question.options.Length; i++)
{
string optionText = $"Option {i + 1}: {question.options[i]}";
// In WebGL: Verzoegerung ueber JavaScript
#if UNITY_WEBGL && !UNITY_EDITOR
SpeakDelayedWebGL(optionText, 1.5f + (i * 1.5f));
#else
Debug.Log($"[TTS] {optionText}");
#endif
}
}
public void SpeakFeedback(bool correct)
{
string message = correct ?
"Richtig! Gut gemacht!" :
"Nicht ganz. Versuch es nochmal!";
Speak(message);
}
public void StopSpeaking()
{
#if UNITY_WEBGL && !UNITY_EDITOR
StopSpeakingWebGL();
#endif
}
// WebGL JavaScript Interop
#if UNITY_WEBGL && !UNITY_EDITOR
[System.Runtime.InteropServices.DllImport("__Internal")]
private static extern void SpeakWebGL(string text);
[System.Runtime.InteropServices.DllImport("__Internal")]
private static extern void SpeakDelayedWebGL(string text, float delaySeconds);
[System.Runtime.InteropServices.DllImport("__Internal")]
private static extern void StopSpeakingWebGL();
#endif
// ==============================================
// Lautstaerke-Einstellungen
// ==============================================
public void SetMasterVolume(float volume)
{
masterVolume = Mathf.Clamp01(volume);
UpdateAllVolumes();
SaveVolumeSettings();
}
public void SetMusicVolume(float volume)
{
musicVolume = Mathf.Clamp01(volume);
if (musicSource != null)
{
musicSource.volume = musicVolume * masterVolume;
}
SaveVolumeSettings();
}
public void SetSFXVolume(float volume)
{
sfxVolume = Mathf.Clamp01(volume);
SaveVolumeSettings();
}
public void SetVoiceVolume(float volume)
{
voiceVolume = Mathf.Clamp01(volume);
if (voiceSource != null)
{
voiceSource.volume = voiceVolume * masterVolume;
}
SaveVolumeSettings();
}
private void UpdateAllVolumes()
{
if (musicSource != null)
musicSource.volume = musicVolume * masterVolume;
if (voiceSource != null)
voiceSource.volume = voiceVolume * masterVolume;
}
private void SaveVolumeSettings()
{
PlayerPrefs.SetFloat("MasterVolume", masterVolume);
PlayerPrefs.SetFloat("MusicVolume", musicVolume);
PlayerPrefs.SetFloat("SFXVolume", sfxVolume);
PlayerPrefs.SetFloat("VoiceVolume", voiceVolume);
PlayerPrefs.Save();
}
private void LoadVolumeSettings()
{
masterVolume = PlayerPrefs.GetFloat("MasterVolume", 1f);
musicVolume = PlayerPrefs.GetFloat("MusicVolume", 0.5f);
sfxVolume = PlayerPrefs.GetFloat("SFXVolume", 1f);
voiceVolume = PlayerPrefs.GetFloat("VoiceVolume", 1f);
UpdateAllVolumes();
}
// Properties fuer UI-Sliders
public float MasterVolume => masterVolume;
public float MusicVolume => musicVolume;
public float SFXVolume => sfxVolume;
public float VoiceVolume => voiceVolume;
}
}

View File

@@ -0,0 +1,247 @@
// ==============================================
// AuthManager.cs - Unity Auth Handler
// ==============================================
// Handles JWT token for Keycloak authentication.
// Supports token via URL parameter or PostMessage from parent frame.
using System;
using System.Runtime.InteropServices;
using UnityEngine;
namespace BreakpilotDrive
{
public class AuthManager : MonoBehaviour
{
public static AuthManager Instance { get; private set; }
[Header("Auth Configuration")]
[SerializeField] private bool requireAuth = false;
[SerializeField] private string devUserId = "dev-user-123";
// Current auth state
private string jwtToken;
private string userId;
private bool isAuthenticated;
// Events
public event Action<string> OnAuthenticated;
public event Action<string> OnAuthFailed;
// JavaScript interface for WebGL
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern string GetTokenFromURL();
[DllImport("__Internal")]
private static extern string GetTokenFromParent();
[DllImport("__Internal")]
private static extern void RequestTokenFromParent();
#endif
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
void Start()
{
InitializeAuth();
}
// ==============================================
// Initialization
// ==============================================
private void InitializeAuth()
{
// Development mode - use dev user
if (!requireAuth || Application.isEditor)
{
SetDevUser();
return;
}
#if UNITY_WEBGL && !UNITY_EDITOR
// Try URL parameter first
string urlToken = GetTokenFromURL();
if (!string.IsNullOrEmpty(urlToken))
{
SetToken(urlToken);
return;
}
// Try parent frame (iframe scenario)
RequestTokenFromParent();
#else
// Non-WebGL builds use dev user
SetDevUser();
#endif
}
private void SetDevUser()
{
userId = devUserId;
isAuthenticated = true;
jwtToken = null;
Debug.Log($"[Auth] Development mode - User: {userId}");
OnAuthenticated?.Invoke(userId);
}
// ==============================================
// Token Management
// ==============================================
public void SetToken(string token)
{
if (string.IsNullOrEmpty(token))
{
OnAuthFailed?.Invoke("Empty token");
return;
}
jwtToken = token;
userId = ExtractUserIdFromToken(token);
if (!string.IsNullOrEmpty(userId))
{
isAuthenticated = true;
Debug.Log($"[Auth] Authenticated - User: {userId}");
OnAuthenticated?.Invoke(userId);
}
else
{
OnAuthFailed?.Invoke("Could not extract user ID from token");
}
}
// Called from JavaScript via SendMessage
public void ReceiveTokenFromJS(string token)
{
Debug.Log("[Auth] Received token from JavaScript");
SetToken(token);
}
// Called from JavaScript if auth fails
public void AuthFailedFromJS(string error)
{
Debug.LogWarning($"[Auth] JavaScript auth failed: {error}");
// Fall back to dev user in development
if (!requireAuth)
{
SetDevUser();
}
else
{
OnAuthFailed?.Invoke(error);
}
}
private string ExtractUserIdFromToken(string token)
{
try
{
// JWT has 3 parts: header.payload.signature
string[] parts = token.Split('.');
if (parts.Length != 3) return null;
// Decode payload (base64)
string payload = parts[1];
// Fix base64 padding
int padding = 4 - (payload.Length % 4);
if (padding < 4) payload += new string('=', padding);
// Replace URL-safe chars
payload = payload.Replace('-', '+').Replace('_', '/');
byte[] bytes = Convert.FromBase64String(payload);
string json = System.Text.Encoding.UTF8.GetString(bytes);
// Simple JSON parsing for "sub" claim
var claims = JsonUtility.FromJson<JWTPayload>(json);
return claims?.sub;
}
catch (Exception e)
{
Debug.LogWarning($"[Auth] Token parsing failed: {e.Message}");
return null;
}
}
[Serializable]
private class JWTPayload
{
public string sub;
public string email;
public string name;
public long exp;
public long iat;
}
// ==============================================
// Public API
// ==============================================
public bool IsAuthenticated => isAuthenticated;
public string UserId => userId;
public string Token => jwtToken;
public bool RequiresAuth => requireAuth;
public string GetAuthHeader()
{
if (string.IsNullOrEmpty(jwtToken))
return null;
return $"Bearer {jwtToken}";
}
public void Logout()
{
jwtToken = null;
userId = null;
isAuthenticated = false;
Debug.Log("[Auth] Logged out");
}
// Check if token is expired
public bool IsTokenExpired()
{
if (string.IsNullOrEmpty(jwtToken))
return true;
try
{
string[] parts = jwtToken.Split('.');
if (parts.Length != 3) return true;
string payload = parts[1];
int padding = 4 - (payload.Length % 4);
if (padding < 4) payload += new string('=', padding);
payload = payload.Replace('-', '+').Replace('_', '/');
byte[] bytes = Convert.FromBase64String(payload);
string json = System.Text.Encoding.UTF8.GetString(bytes);
var claims = JsonUtility.FromJson<JWTPayload>(json);
if (claims?.exp > 0)
{
var expTime = DateTimeOffset.FromUnixTimeSeconds(claims.exp).UtcDateTime;
return DateTime.UtcNow > expTime;
}
}
catch { }
return false;
}
}
}

View File

@@ -0,0 +1,292 @@
// ==============================================
// DifficultyManager.cs - Schwierigkeits-Steuerung
// ==============================================
// Passt die Spielschwierigkeit dynamisch an
// basierend auf Lernniveau und Spielerleistung.
using System;
using UnityEngine;
namespace BreakpilotDrive
{
public class DifficultyManager : MonoBehaviour
{
public static DifficultyManager Instance { get; private set; }
[Header("Schwierigkeits-Bereich")]
[SerializeField] private int minDifficulty = 1;
[SerializeField] private int maxDifficulty = 5;
[Header("Dynamische Anpassung")]
[SerializeField] private bool enableDynamicDifficulty = true;
[SerializeField] private int questionsForEvaluation = 5;
[SerializeField] private float accuracyToIncrease = 0.8f; // 80% richtig = schwerer
[SerializeField] private float accuracyToDecrease = 0.4f; // 40% richtig = leichter
[Header("Geschwindigkeits-Steigerung")]
[SerializeField] private bool enableSpeedIncrease = true;
[SerializeField] private float speedIncreaseInterval = 30f; // Alle X Sekunden
[SerializeField] private float speedIncreaseAmount = 0.5f; // Um X erhoehen
// Aktueller Zustand
private int currentDifficulty = 3;
private GameDifficulty currentSettings;
private float timeSinceLastSpeedIncrease = 0f;
// Statistik fuer dynamische Anpassung
private int recentQuestionsAnswered = 0;
private int recentQuestionsCorrect = 0;
// Events
public event Action<int> OnDifficultyChanged;
public event Action<GameDifficulty> OnSettingsUpdated;
// Properties
public int CurrentDifficulty => currentDifficulty;
public GameDifficulty CurrentSettings => currentSettings;
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
void Start()
{
// Quiz-Events abonnieren
if (QuizManager.Instance != null)
{
QuizManager.Instance.OnQuestionAnswered += OnQuestionAnswered;
}
// Initiale Schwierigkeit laden
LoadDifficulty(currentDifficulty);
}
void OnDestroy()
{
if (QuizManager.Instance != null)
{
QuizManager.Instance.OnQuestionAnswered -= OnQuestionAnswered;
}
}
void Update()
{
if (GameManager.Instance?.CurrentState != GameState.Playing)
return;
// Geschwindigkeit graduell erhoehen
if (enableSpeedIncrease)
{
timeSinceLastSpeedIncrease += Time.deltaTime;
if (timeSinceLastSpeedIncrease >= speedIncreaseInterval)
{
timeSinceLastSpeedIncrease = 0f;
IncreaseSpeed();
}
}
}
// ==============================================
// Schwierigkeit laden
// ==============================================
public void LoadDifficulty(int level)
{
currentDifficulty = Mathf.Clamp(level, minDifficulty, maxDifficulty);
if (BreakpilotAPI.Instance != null)
{
StartCoroutine(BreakpilotAPI.Instance.GetDifficulty(currentDifficulty,
onSuccess: (settings) =>
{
currentSettings = settings;
ApplySettings(settings);
OnSettingsUpdated?.Invoke(settings);
Debug.Log($"Schwierigkeit {currentDifficulty} geladen");
},
onError: (error) =>
{
Debug.LogWarning($"Konnte Schwierigkeit nicht laden: {error}");
// Fallback zu Default-Werten
currentSettings = GetDefaultSettings(currentDifficulty);
ApplySettings(currentSettings);
}
));
}
else
{
currentSettings = GetDefaultSettings(currentDifficulty);
ApplySettings(currentSettings);
}
OnDifficultyChanged?.Invoke(currentDifficulty);
}
private void ApplySettings(GameDifficulty settings)
{
// Geschwindigkeit anwenden
if (TrackGenerator.Instance != null)
{
TrackGenerator.Instance.SetSpeed(settings.lane_speed);
}
if (PlayerController.Instance != null)
{
PlayerController.Instance.SetSpeed(settings.lane_speed);
}
// Hindernis-Frequenz anwenden
// ObstacleSpawner liest die Settings direkt aus GameManager
Debug.Log($"Settings angewendet: Speed={settings.lane_speed}, " +
$"Obstacles={settings.obstacle_frequency}, " +
$"Hints={settings.hints_enabled}");
}
// ==============================================
// Dynamische Anpassung
// ==============================================
private void OnQuestionAnswered(bool correct, int points)
{
if (!enableDynamicDifficulty) return;
recentQuestionsAnswered++;
if (correct) recentQuestionsCorrect++;
// Evaluation nach X Fragen
if (recentQuestionsAnswered >= questionsForEvaluation)
{
EvaluateAndAdjust();
}
}
private void EvaluateAndAdjust()
{
float accuracy = (float)recentQuestionsCorrect / recentQuestionsAnswered;
Debug.Log($"Quiz-Evaluation: {recentQuestionsCorrect}/{recentQuestionsAnswered} " +
$"= {accuracy:P0}");
if (accuracy >= accuracyToIncrease && currentDifficulty < maxDifficulty)
{
// Spieler ist gut - Schwierigkeit erhoehen
IncreaseDifficulty();
}
else if (accuracy <= accuracyToDecrease && currentDifficulty > minDifficulty)
{
// Spieler hat Probleme - Schwierigkeit verringern
DecreaseDifficulty();
}
// Reset fuer naechste Evaluation
recentQuestionsAnswered = 0;
recentQuestionsCorrect = 0;
}
public void IncreaseDifficulty()
{
if (currentDifficulty < maxDifficulty)
{
LoadDifficulty(currentDifficulty + 1);
Debug.Log($"Schwierigkeit erhoeht auf {currentDifficulty}");
}
}
public void DecreaseDifficulty()
{
if (currentDifficulty > minDifficulty)
{
LoadDifficulty(currentDifficulty - 1);
Debug.Log($"Schwierigkeit verringert auf {currentDifficulty}");
}
}
// ==============================================
// Geschwindigkeits-Steigerung
// ==============================================
private void IncreaseSpeed()
{
if (currentSettings == null) return;
float newSpeed = currentSettings.lane_speed + speedIncreaseAmount;
// Maximal-Geschwindigkeit basierend auf Difficulty
float maxSpeed = 5f + (currentDifficulty * 2f);
newSpeed = Mathf.Min(newSpeed, maxSpeed);
currentSettings.lane_speed = newSpeed;
if (TrackGenerator.Instance != null)
{
TrackGenerator.Instance.SetSpeed(newSpeed);
}
Debug.Log($"Geschwindigkeit erhoeht auf {newSpeed:F1}");
}
// ==============================================
// Default-Werte (Fallback)
// ==============================================
private GameDifficulty GetDefaultSettings(int level)
{
return new GameDifficulty
{
lane_speed = 4f + level,
obstacle_frequency = 0.3f + (level * 0.1f),
power_up_chance = 0.4f - (level * 0.05f),
question_complexity = level,
answer_time = 15 - (level * 2),
hints_enabled = level <= 2,
speech_speed = 0.8f + (level * 0.1f)
};
}
// ==============================================
// Oeffentliche Methoden
// ==============================================
public void SetDifficulty(int level)
{
LoadDifficulty(level);
}
public void ResetForNewGame()
{
recentQuestionsAnswered = 0;
recentQuestionsCorrect = 0;
timeSinceLastSpeedIncrease = 0f;
// Schwierigkeit von User-Level laden
if (BreakpilotAPI.Instance != null)
{
var cachedLevel = BreakpilotAPI.Instance.GetCachedLevel();
if (cachedLevel != null)
{
LoadDifficulty(cachedLevel.overall_level);
}
}
}
public float GetCurrentSpeed()
{
return currentSettings?.lane_speed ?? 5f;
}
public bool AreHintsEnabled()
{
return currentSettings?.hints_enabled ?? false;
}
public int GetAnswerTime()
{
return currentSettings?.answer_time ?? 10;
}
}
}

View File

@@ -0,0 +1,317 @@
// ==============================================
// GameManager.cs - Zentrale Spielsteuerung
// ==============================================
// Verwaltet Spielzustand, Score, Leben und
// koordiniert alle anderen Manager.
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace BreakpilotDrive
{
public enum GameState
{
MainMenu,
Playing,
Paused,
QuizActive,
GameOver
}
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
[Header("Spieleinstellungen")]
[SerializeField] private int startLives = 3;
[SerializeField] private float pauseQuestionInterval = 500f; // Alle X Meter
[Header("UI Referenzen")]
[SerializeField] private GameObject gameOverPanel;
[SerializeField] private GameObject pausePanel;
// Spielzustand
private GameState currentState = GameState.MainMenu;
private int score = 0;
private int lives;
private float distanceTraveled = 0f;
private float playTime = 0f;
private float nextPauseQuestionDistance;
// Schwierigkeit (von API geladen)
private int currentDifficulty = 3;
private GameDifficulty difficultySettings;
// Events
public event Action<int> OnScoreChanged;
public event Action<int> OnLivesChanged;
public event Action<float> OnDistanceChanged;
public event Action<GameState> OnStateChanged;
// Properties
public GameState CurrentState => currentState;
public int Score => score;
public int Lives => lives;
public float DistanceTraveled => distanceTraveled;
public float PlayTime => playTime;
public int CurrentDifficulty => currentDifficulty;
public GameDifficulty DifficultySettings => difficultySettings;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
void Start()
{
// UI initial verstecken
if (gameOverPanel) gameOverPanel.SetActive(false);
if (pausePanel) pausePanel.SetActive(false);
}
void Update()
{
if (currentState == GameState.Playing)
{
// Spielzeit zaehlen
playTime += Time.deltaTime;
// Pause-Fragen pruefen
if (distanceTraveled >= nextPauseQuestionDistance)
{
TriggerPauseQuestion();
}
// Escape fuer Pause
if (Input.GetKeyDown(KeyCode.Escape))
{
PauseGame();
}
}
else if (currentState == GameState.Paused)
{
if (Input.GetKeyDown(KeyCode.Escape))
{
ResumeGame();
}
}
}
// ==============================================
// Spiel starten
// ==============================================
public void StartGame(string userId = "guest")
{
// Werte zuruecksetzen
score = 0;
lives = startLives;
distanceTraveled = 0f;
playTime = 0f;
nextPauseQuestionDistance = pauseQuestionInterval;
// Events ausloesen
OnScoreChanged?.Invoke(score);
OnLivesChanged?.Invoke(lives);
// Schwierigkeit laden
if (BreakpilotAPI.Instance != null)
{
StartCoroutine(BreakpilotAPI.Instance.GetLearningLevel(userId,
onSuccess: (level) =>
{
currentDifficulty = level.overall_level;
LoadDifficultySettings();
}
));
}
else
{
LoadDifficultySettings();
}
// Quiz-Statistik zuruecksetzen
if (QuizManager.Instance != null)
{
QuizManager.Instance.ResetStatistics();
}
SetState(GameState.Playing);
}
private void LoadDifficultySettings()
{
if (BreakpilotAPI.Instance != null)
{
StartCoroutine(BreakpilotAPI.Instance.GetDifficulty(currentDifficulty,
onSuccess: (settings) =>
{
difficultySettings = settings;
Debug.Log($"Schwierigkeit {currentDifficulty} geladen: Speed={settings.lane_speed}");
}
));
}
}
// ==============================================
// Score-System
// ==============================================
public void AddScore(int points)
{
score = Mathf.Max(0, score + points);
OnScoreChanged?.Invoke(score);
}
public void AddDistance(float distance)
{
distanceTraveled += distance;
OnDistanceChanged?.Invoke(distanceTraveled);
}
// ==============================================
// Leben-System
// ==============================================
public void LoseLife()
{
lives--;
OnLivesChanged?.Invoke(lives);
if (lives <= 0)
{
GameOver();
}
}
public void GainLife()
{
lives++;
OnLivesChanged?.Invoke(lives);
}
// ==============================================
// Pause-Fragen
// ==============================================
private void TriggerPauseQuestion()
{
nextPauseQuestionDistance += pauseQuestionInterval;
if (BreakpilotAPI.Instance != null && QuizManager.Instance != null)
{
// Pause-Frage aus Cache holen
var questions = BreakpilotAPI.Instance.GetCachedQuestions();
var pauseQuestions = questions.FindAll(q => q.quiz_mode == "pause");
if (pauseQuestions.Count > 0)
{
int index = UnityEngine.Random.Range(0, pauseQuestions.Count);
QuizManager.Instance.ShowPauseQuestion(pauseQuestions[index]);
}
}
}
// ==============================================
// Spielzustand
// ==============================================
public void SetState(GameState newState)
{
currentState = newState;
OnStateChanged?.Invoke(newState);
switch (newState)
{
case GameState.Playing:
Time.timeScale = 1f;
break;
case GameState.Paused:
case GameState.QuizActive:
Time.timeScale = 0f;
break;
case GameState.GameOver:
Time.timeScale = 0f;
break;
}
}
public void PauseGame()
{
if (currentState == GameState.Playing)
{
SetState(GameState.Paused);
if (pausePanel) pausePanel.SetActive(true);
}
}
public void ResumeGame()
{
if (currentState == GameState.Paused)
{
SetState(GameState.Playing);
if (pausePanel) pausePanel.SetActive(false);
}
}
// ==============================================
// Spiel beenden
// ==============================================
public void GameOver()
{
SetState(GameState.GameOver);
if (gameOverPanel) gameOverPanel.SetActive(true);
// Session speichern
SaveSession();
}
private void SaveSession()
{
if (BreakpilotAPI.Instance == null) return;
GameSession session = new GameSession
{
user_id = "guest", // TODO: Echte User-ID
game_mode = "video",
duration_seconds = Mathf.RoundToInt(playTime),
distance_traveled = distanceTraveled,
score = score,
questions_answered = QuizManager.Instance?.GetQuestionsAnswered() ?? 0,
questions_correct = QuizManager.Instance?.GetQuestionsCorrect() ?? 0,
difficulty_level = currentDifficulty
};
StartCoroutine(BreakpilotAPI.Instance.SaveGameSession(session,
onSuccess: (response) =>
{
Debug.Log($"Session gespeichert: {response.session_id}");
if (response.new_level.HasValue)
{
Debug.Log($"Level Up! Neues Level: {response.new_level}");
}
}
));
}
// ==============================================
// Neustart
// ==============================================
public void RestartGame()
{
if (gameOverPanel) gameOverPanel.SetActive(false);
StartGame();
}
public void LoadMainMenu()
{
Time.timeScale = 1f;
SceneManager.LoadScene("MainMenu");
}
}
}

View File

@@ -0,0 +1,373 @@
// ==============================================
// PlayerController.cs - Spieler-Steuerung
// ==============================================
// 3-Spur-System mit Tastatur und Touch-Eingabe.
// Kollisionserkennung fuer Hindernisse und Items.
using System;
using UnityEngine;
namespace BreakpilotDrive
{
public class PlayerController : MonoBehaviour
{
public static PlayerController Instance { get; private set; }
[Header("Bewegung")]
[SerializeField] private float laneDistance = 3f; // Abstand zwischen Spuren
[SerializeField] private float laneSwitchSpeed = 10f; // Geschwindigkeit beim Spurwechsel
[SerializeField] private float forwardSpeed = 10f; // Vorwaertsgeschwindigkeit
[Header("Spuren")]
[SerializeField] private int currentLane = 1; // 0=Links, 1=Mitte, 2=Rechts
private int targetLane = 1;
[Header("Touch-Steuerung")]
[SerializeField] private float swipeThreshold = 50f; // Pixel fuer Swipe-Erkennung
private Vector2 touchStartPos;
private bool isSwiping = false;
[Header("Effekte")]
[SerializeField] private ParticleSystem crashEffect;
[SerializeField] private ParticleSystem collectEffect;
// Zustand
private bool isAlive = true;
private bool isInvincible = false;
private float invincibleTimer = 0f;
// Events
public event Action OnCrash;
public event Action<string> OnItemCollected;
// Components
private Rigidbody rb;
private Animator animator;
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
rb = GetComponent<Rigidbody>();
animator = GetComponent<Animator>();
}
void Start()
{
// Startposition (mittlere Spur)
currentLane = 1;
targetLane = 1;
UpdateTargetPosition();
// Geschwindigkeit von Difficulty laden
if (GameManager.Instance?.DifficultySettings != null)
{
forwardSpeed = GameManager.Instance.DifficultySettings.lane_speed;
}
}
void Update()
{
if (!isAlive || GameManager.Instance?.CurrentState != GameState.Playing)
return;
// Eingabe verarbeiten
HandleInput();
// Zur Zielspur bewegen
MoveToTargetLane();
// Vorwaerts bewegen
MoveForward();
// Unverwundbarkeit Timer
if (isInvincible)
{
invincibleTimer -= Time.deltaTime;
if (invincibleTimer <= 0)
{
isInvincible = false;
// Blink-Effekt beenden
SetVisibility(true);
}
}
// Distanz zum GameManager melden
GameManager.Instance?.AddDistance(forwardSpeed * Time.deltaTime);
}
// ==============================================
// Eingabe-Verarbeitung
// ==============================================
private void HandleInput()
{
// Tastatur-Eingabe
if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow))
{
MoveLane(-1);
}
else if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow))
{
MoveLane(1);
}
// Direkte Spurwahl mit Zahlen (auch fuer Quiz)
if (Input.GetKeyDown(KeyCode.Alpha1) || Input.GetKeyDown(KeyCode.Keypad1))
{
SetLane(0);
}
else if (Input.GetKeyDown(KeyCode.Alpha2) || Input.GetKeyDown(KeyCode.Keypad2))
{
SetLane(1);
}
else if (Input.GetKeyDown(KeyCode.Alpha3) || Input.GetKeyDown(KeyCode.Keypad3))
{
SetLane(2);
}
// Touch-Eingabe
HandleTouchInput();
}
private void HandleTouchInput()
{
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
switch (touch.phase)
{
case TouchPhase.Began:
touchStartPos = touch.position;
isSwiping = true;
break;
case TouchPhase.Moved:
case TouchPhase.Ended:
if (isSwiping)
{
Vector2 swipeDelta = touch.position - touchStartPos;
if (Mathf.Abs(swipeDelta.x) > swipeThreshold)
{
if (swipeDelta.x > 0)
{
MoveLane(1); // Rechts
}
else
{
MoveLane(-1); // Links
}
isSwiping = false;
}
}
break;
}
}
// Maus-Simulation (fuer Editor-Tests)
if (Input.GetMouseButtonDown(0))
{
touchStartPos = Input.mousePosition;
isSwiping = true;
}
else if (Input.GetMouseButtonUp(0) && isSwiping)
{
Vector2 swipeDelta = (Vector2)Input.mousePosition - touchStartPos;
if (Mathf.Abs(swipeDelta.x) > swipeThreshold)
{
MoveLane(swipeDelta.x > 0 ? 1 : -1);
}
isSwiping = false;
}
}
// ==============================================
// Spur-Bewegung
// ==============================================
private void MoveLane(int direction)
{
targetLane = Mathf.Clamp(targetLane + direction, 0, 2);
}
private void SetLane(int lane)
{
targetLane = Mathf.Clamp(lane, 0, 2);
}
private void MoveToTargetLane()
{
float targetX = (targetLane - 1) * laneDistance;
Vector3 targetPos = new Vector3(targetX, transform.position.y, transform.position.z);
transform.position = Vector3.Lerp(
transform.position,
targetPos,
laneSwitchSpeed * Time.deltaTime
);
// Aktuelle Spur aktualisieren
currentLane = targetLane;
}
private void MoveForward()
{
// Spieler bleibt stationaer, Welt bewegt sich
// Alternativ: transform.Translate(Vector3.forward * forwardSpeed * Time.deltaTime);
}
private void UpdateTargetPosition()
{
float targetX = (targetLane - 1) * laneDistance;
transform.position = new Vector3(targetX, transform.position.y, transform.position.z);
}
// ==============================================
// Kollisionen
// ==============================================
void OnTriggerEnter(Collider other)
{
if (!isAlive) return;
// Hindernis
if (other.CompareTag("Obstacle"))
{
if (!isInvincible)
{
Crash();
}
}
// Sammelbare Items
else if (other.CompareTag("Coin"))
{
CollectItem("coin", 100);
Destroy(other.gameObject);
}
else if (other.CompareTag("Star"))
{
CollectItem("star", 500);
Destroy(other.gameObject);
}
else if (other.CompareTag("Shield"))
{
CollectItem("shield", 0);
ActivateShield(5f);
Destroy(other.gameObject);
}
// Quiz-Trigger
else if (other.CompareTag("QuizTrigger"))
{
VisualTrigger trigger = other.GetComponent<VisualTrigger>();
if (trigger != null)
{
QuizManager.Instance?.ShowQuickQuestion(trigger.TriggerType);
}
}
}
// ==============================================
// Crash-Handling
// ==============================================
private void Crash()
{
// Effekt abspielen
if (crashEffect != null)
{
crashEffect.Play();
}
// Animation
if (animator != null)
{
animator.SetTrigger("Crash");
}
// Leben verlieren
GameManager.Instance?.LoseLife();
// Kurze Unverwundbarkeit
isInvincible = true;
invincibleTimer = 2f;
StartCoroutine(BlinkEffect());
OnCrash?.Invoke();
}
private System.Collections.IEnumerator BlinkEffect()
{
while (isInvincible)
{
SetVisibility(false);
yield return new WaitForSeconds(0.1f);
SetVisibility(true);
yield return new WaitForSeconds(0.1f);
}
}
private void SetVisibility(bool visible)
{
Renderer[] renderers = GetComponentsInChildren<Renderer>();
foreach (var r in renderers)
{
r.enabled = visible;
}
}
// ==============================================
// Item-Collection
// ==============================================
private void CollectItem(string itemType, int points)
{
// Effekt abspielen
if (collectEffect != null)
{
collectEffect.Play();
}
// Punkte hinzufuegen
if (points > 0)
{
GameManager.Instance?.AddScore(points);
}
OnItemCollected?.Invoke(itemType);
}
private void ActivateShield(float duration)
{
isInvincible = true;
invincibleTimer = duration;
// TODO: Schild-Visualisierung
}
// ==============================================
// Oeffentliche Methoden
// ==============================================
public void SetSpeed(float speed)
{
forwardSpeed = speed;
}
public int GetCurrentLane()
{
return currentLane;
}
public void Reset()
{
isAlive = true;
isInvincible = false;
currentLane = 1;
targetLane = 1;
UpdateTargetPosition();
}
}
}

View File

@@ -0,0 +1,54 @@
// ==============================================
// AuthPlugin.jslib - WebGL JavaScript Bridge
// ==============================================
// Place in Assets/Plugins/WebGL/
mergeInto(LibraryManager.library, {
// Get token from URL parameter (?token=xxx)
GetTokenFromURL: function() {
var urlParams = new URLSearchParams(window.location.search);
var token = urlParams.get('token');
if (token) {
var bufferSize = lengthBytesUTF8(token) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(token, buffer, bufferSize);
return buffer;
}
return null;
},
// Get token from parent frame (for iframe embedding)
GetTokenFromParent: function() {
if (window.breakpilotToken) {
var token = window.breakpilotToken;
var bufferSize = lengthBytesUTF8(token) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(token, buffer, bufferSize);
return buffer;
}
return null;
},
// Request token from parent frame via postMessage
RequestTokenFromParent: function() {
// Listen for token from parent
window.addEventListener('message', function(event) {
// Verify origin in production!
if (event.data && event.data.type === 'breakpilot_token') {
window.breakpilotToken = event.data.token;
// Send to Unity
if (window.unityInstance) {
window.unityInstance.SendMessage('AuthManager', 'ReceiveTokenFromJS', event.data.token);
}
}
}, false);
// Request token from parent
if (window.parent !== window) {
window.parent.postMessage({ type: 'breakpilot_request_token' }, '*');
}
}
});

View File

@@ -0,0 +1,98 @@
// ==============================================
// WebSpeech.jslib - Text-to-Speech fuer WebGL
// ==============================================
// Kopiere diese Datei nach Assets/Plugins/WebGL/
// um TTS im Browser zu aktivieren.
mergeInto(LibraryManager.library, {
// Spricht den Text sofort
SpeakWebGL: function(textPtr) {
var text = UTF8ToString(textPtr);
if ('speechSynthesis' in window) {
// Vorherige Sprache stoppen
window.speechSynthesis.cancel();
var utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'de-DE';
utterance.rate = 0.9;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// Deutsche Stimme suchen
var voices = window.speechSynthesis.getVoices();
var germanVoice = voices.find(function(voice) {
return voice.lang.startsWith('de');
});
if (germanVoice) {
utterance.voice = germanVoice;
}
window.speechSynthesis.speak(utterance);
} else {
console.warn('Text-to-Speech wird von diesem Browser nicht unterstuetzt.');
}
},
// Spricht den Text nach einer Verzoegerung
SpeakDelayedWebGL: function(textPtr, delaySeconds) {
var text = UTF8ToString(textPtr);
setTimeout(function() {
if ('speechSynthesis' in window) {
var utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'de-DE';
utterance.rate = 0.9;
utterance.pitch = 1.0;
utterance.volume = 1.0;
var voices = window.speechSynthesis.getVoices();
var germanVoice = voices.find(function(voice) {
return voice.lang.startsWith('de');
});
if (germanVoice) {
utterance.voice = germanVoice;
}
window.speechSynthesis.speak(utterance);
}
}, delaySeconds * 1000);
},
// Stoppt alle laufenden Sprach-Ausgaben
StopSpeakingWebGL: function() {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
},
// Prueft ob TTS verfuegbar ist
IsTTSAvailableWebGL: function() {
return 'speechSynthesis' in window;
},
// Gibt verfuegbare Stimmen zurueck (als JSON)
GetAvailableVoicesWebGL: function() {
if ('speechSynthesis' in window) {
var voices = window.speechSynthesis.getVoices();
var germanVoices = voices.filter(function(voice) {
return voice.lang.startsWith('de');
}).map(function(voice) {
return {
name: voice.name,
lang: voice.lang,
local: voice.localService
};
});
var json = JSON.stringify(germanVoices);
var bufferSize = lengthBytesUTF8(json) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(json, buffer, bufferSize);
return buffer;
}
return null;
}
});

View File

@@ -0,0 +1,318 @@
// ==============================================
// QuizManager.cs - Quiz-Steuerung
// ==============================================
// Verwaltet Quick-Fragen (waehrend Fahrt) und
// Pause-Fragen (Spiel haelt an).
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace BreakpilotDrive
{
public class QuizManager : MonoBehaviour
{
public static QuizManager Instance { get; private set; }
[Header("UI Referenzen")]
[SerializeField] private GameObject quickQuizPanel;
[SerializeField] private GameObject pauseQuizPanel;
[SerializeField] private TextMeshProUGUI questionText;
[SerializeField] private Button[] answerButtons;
[SerializeField] private TextMeshProUGUI timerText;
[SerializeField] private Slider timerSlider;
[Header("Audio (fuer Audio-Version)")]
[SerializeField] private AudioSource audioSource;
[SerializeField] private bool isAudioMode = false;
[Header("Einstellungen")]
[SerializeField] private int pointsCorrect = 500;
[SerializeField] private int pointsWrong = -100;
[SerializeField] private float pauseQuestionInterval = 500f; // Alle X Meter
// Aktueller Zustand
private QuizQuestion currentQuestion;
private bool isQuizActive = false;
private float timeRemaining;
private Coroutine timerCoroutine;
// Events
public event Action<bool, int> OnQuestionAnswered; // (correct, points)
public event Action OnQuizStarted;
public event Action OnQuizEnded;
// Statistik
private int questionsAnswered = 0;
private int questionsCorrect = 0;
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
void Start()
{
// UI initial verstecken
if (quickQuizPanel) quickQuizPanel.SetActive(false);
if (pauseQuizPanel) pauseQuizPanel.SetActive(false);
// Answer Buttons einrichten
for (int i = 0; i < answerButtons.Length; i++)
{
int index = i; // Capture fuer Lambda
answerButtons[i].onClick.AddListener(() => OnAnswerSelected(index));
}
}
void Update()
{
// Keyboard-Eingabe fuer Antworten (1-4)
if (isQuizActive && currentQuestion != null)
{
if (Input.GetKeyDown(KeyCode.Alpha1) || Input.GetKeyDown(KeyCode.Keypad1))
OnAnswerSelected(0);
else if (Input.GetKeyDown(KeyCode.Alpha2) || Input.GetKeyDown(KeyCode.Keypad2))
OnAnswerSelected(1);
else if (Input.GetKeyDown(KeyCode.Alpha3) || Input.GetKeyDown(KeyCode.Keypad3))
OnAnswerSelected(2);
else if (Input.GetKeyDown(KeyCode.Alpha4) || Input.GetKeyDown(KeyCode.Keypad4))
OnAnswerSelected(3);
}
}
// ==============================================
// Quick Quiz (waehrend der Fahrt)
// ==============================================
public void ShowQuickQuestion(string visualTrigger)
{
if (isQuizActive) return;
// Frage aus Cache holen
int difficulty = BreakpilotAPI.Instance != null ?
(int)GetCurrentDifficulty() : 3;
QuizQuestion question = BreakpilotAPI.Instance?.GetQuestionForTrigger(visualTrigger, difficulty);
if (question == null)
{
Debug.LogWarning($"Keine Frage fuer Trigger '{visualTrigger}' gefunden");
return;
}
currentQuestion = question;
isQuizActive = true;
// UI anzeigen
if (quickQuizPanel) quickQuizPanel.SetActive(true);
DisplayQuestion(question);
// Timer starten
float timeLimit = question.time_limit_seconds > 0 ? question.time_limit_seconds : 5f;
timerCoroutine = StartCoroutine(QuickQuizTimer(timeLimit));
// Audio-Version: Frage vorlesen
if (isAudioMode)
{
SpeakQuestion(question);
}
OnQuizStarted?.Invoke();
}
private IEnumerator QuickQuizTimer(float duration)
{
timeRemaining = duration;
while (timeRemaining > 0)
{
timeRemaining -= Time.deltaTime;
// UI updaten
if (timerText) timerText.text = $"{timeRemaining:F1}s";
if (timerSlider) timerSlider.value = timeRemaining / duration;
yield return null;
}
// Zeit abgelaufen = falsche Antwort
OnAnswerSelected(-1);
}
// ==============================================
// Pause Quiz (Spiel haelt an)
// ==============================================
public void ShowPauseQuestion(QuizQuestion question)
{
if (isQuizActive) return;
currentQuestion = question;
isQuizActive = true;
// Spiel pausieren
Time.timeScale = 0;
// UI anzeigen
if (pauseQuizPanel) pauseQuizPanel.SetActive(true);
DisplayQuestion(question);
// Audio-Version: Frage vorlesen
if (isAudioMode)
{
SpeakQuestion(question);
}
OnQuizStarted?.Invoke();
}
// ==============================================
// Antwort verarbeiten
// ==============================================
private void OnAnswerSelected(int index)
{
if (!isQuizActive || currentQuestion == null) return;
// Timer stoppen
if (timerCoroutine != null)
{
StopCoroutine(timerCoroutine);
timerCoroutine = null;
}
bool isCorrect = index == currentQuestion.correct_index;
int points = isCorrect ? pointsCorrect : pointsWrong;
questionsAnswered++;
if (isCorrect) questionsCorrect++;
// Visuelles Feedback
ShowAnswerFeedback(isCorrect, currentQuestion.correct_index);
// Audio Feedback
if (isAudioMode)
{
SpeakFeedback(isCorrect);
}
// Event ausloesen
OnQuestionAnswered?.Invoke(isCorrect, points);
// Quiz beenden (mit kurzer Verzoegerung fuer Feedback)
StartCoroutine(EndQuizAfterDelay(isCorrect ? 0.5f : 1f));
}
private IEnumerator EndQuizAfterDelay(float delay)
{
// Bei pausiertem Spiel: unscaled Zeit nutzen
yield return new WaitForSecondsRealtime(delay);
EndQuiz();
}
private void EndQuiz()
{
isQuizActive = false;
currentQuestion = null;
// UI verstecken
if (quickQuizPanel) quickQuizPanel.SetActive(false);
if (pauseQuizPanel) pauseQuizPanel.SetActive(false);
// Spiel fortsetzen (falls pausiert)
Time.timeScale = 1;
OnQuizEnded?.Invoke();
}
// ==============================================
// UI Helper
// ==============================================
private void DisplayQuestion(QuizQuestion question)
{
if (questionText) questionText.text = question.question_text;
for (int i = 0; i < answerButtons.Length; i++)
{
if (i < question.options.Length)
{
answerButtons[i].gameObject.SetActive(true);
var buttonText = answerButtons[i].GetComponentInChildren<TextMeshProUGUI>();
if (buttonText) buttonText.text = $"[{i + 1}] {question.options[i]}";
}
else
{
answerButtons[i].gameObject.SetActive(false);
}
}
}
private void ShowAnswerFeedback(bool correct, int correctIndex)
{
// Richtige Antwort gruen markieren
if (correctIndex >= 0 && correctIndex < answerButtons.Length)
{
var colors = answerButtons[correctIndex].colors;
colors.normalColor = Color.green;
answerButtons[correctIndex].colors = colors;
}
// TODO: Animation, Sound, Partikel
}
// ==============================================
// Audio-Version
// ==============================================
private void SpeakQuestion(QuizQuestion question)
{
// TODO: Text-to-Speech Integration
// Option 1: Voraufgenommene Audio-Dateien
// Option 2: Web-TTS API
// Option 3: Unity Speech Synthesis (platform-abhaengig)
Debug.Log($"[TTS] {question.question_text}");
for (int i = 0; i < question.options.Length; i++)
{
Debug.Log($"[TTS] {i + 1} fuer {question.options[i]}");
}
}
private void SpeakFeedback(bool correct)
{
string message = correct ? "Richtig! Gut gemacht!" : "Nicht ganz. Versuch es nochmal!";
Debug.Log($"[TTS] {message}");
}
// ==============================================
// Statistik
// ==============================================
public int GetQuestionsAnswered() => questionsAnswered;
public int GetQuestionsCorrect() => questionsCorrect;
public float GetAccuracy()
{
if (questionsAnswered == 0) return 0;
return (float)questionsCorrect / questionsAnswered;
}
public void ResetStatistics()
{
questionsAnswered = 0;
questionsCorrect = 0;
}
private float GetCurrentDifficulty()
{
// TODO: Aus GameManager holen
return 3;
}
}
}

View File

@@ -0,0 +1,248 @@
// ==============================================
// ObstacleSpawner.cs - Hindernis-Generator
// ==============================================
// Spawnt Hindernisse, Items und Quiz-Trigger
// basierend auf Schwierigkeitsgrad.
using UnityEngine;
namespace BreakpilotDrive
{
public class ObstacleSpawner : MonoBehaviour
{
public static ObstacleSpawner Instance { get; private set; }
[Header("Hindernis-Prefabs")]
[SerializeField] private GameObject[] obstaclePrefabs; // Verschiedene Hindernisse
[SerializeField] private GameObject[] largObstaclePrefabs; // Grosse Hindernisse (2 Spuren)
[Header("Item-Prefabs")]
[SerializeField] private GameObject coinPrefab;
[SerializeField] private GameObject starPrefab;
[SerializeField] private GameObject shieldPrefab;
[Header("Quiz-Trigger-Prefabs")]
[SerializeField] private GameObject bridgePrefab;
[SerializeField] private GameObject treePrefab;
[SerializeField] private GameObject housePrefab;
[Header("Spawn-Einstellungen")]
[SerializeField] private float laneDistance = 3f;
[SerializeField] private float minObstacleSpacing = 10f;
[SerializeField] private float maxObstacleSpacing = 30f;
[Header("Wahrscheinlichkeiten (0-1)")]
[SerializeField] private float obstacleChance = 0.6f;
[SerializeField] private float coinChance = 0.3f;
[SerializeField] private float starChance = 0.05f;
[SerializeField] private float shieldChance = 0.02f;
[SerializeField] private float quizTriggerChance = 0.1f;
// Interner Zustand
private float lastObstacleZ = 0f;
private int quizTriggerCount = 0;
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
void Start()
{
// Wahrscheinlichkeiten von Difficulty laden
UpdateFromDifficulty();
}
private void UpdateFromDifficulty()
{
if (GameManager.Instance?.DifficultySettings != null)
{
var settings = GameManager.Instance.DifficultySettings;
obstacleChance = settings.obstacle_frequency;
// Power-Up Chance beeinflusst Items
float powerUpChance = settings.power_up_chance;
coinChance = powerUpChance * 0.8f;
starChance = powerUpChance * 0.15f;
shieldChance = powerUpChance * 0.05f;
}
}
// ==============================================
// Spawning auf Segment
// ==============================================
public void SpawnOnSegment(GameObject segment, float segmentStartZ)
{
float segmentLength = 50f; // Sollte mit TrackGenerator uebereinstimmen
float currentZ = segmentStartZ + minObstacleSpacing;
while (currentZ < segmentStartZ + segmentLength - minObstacleSpacing)
{
// Zufaelliger Abstand zum naechsten Objekt
float spacing = Random.Range(minObstacleSpacing, maxObstacleSpacing);
// Was spawnen?
SpawnAtPosition(segment.transform, currentZ);
currentZ += spacing;
}
}
private void SpawnAtPosition(Transform parent, float zPosition)
{
float roll = Random.value;
// Quiz-Trigger (selten, aber wichtig)
if (roll < quizTriggerChance && quizTriggerCount < 3)
{
SpawnQuizTrigger(parent, zPosition);
quizTriggerCount++;
return;
}
// Hindernis
roll = Random.value;
if (roll < obstacleChance)
{
SpawnObstacle(parent, zPosition);
}
// Items (koennen neben Hindernissen sein)
roll = Random.value;
if (roll < coinChance)
{
SpawnCoinRow(parent, zPosition);
}
else if (roll < coinChance + starChance)
{
SpawnItem(starPrefab, parent, zPosition, GetRandomLane());
}
else if (roll < coinChance + starChance + shieldChance)
{
SpawnItem(shieldPrefab, parent, zPosition, GetRandomLane());
}
}
// ==============================================
// Hindernisse
// ==============================================
private void SpawnObstacle(Transform parent, float zPosition)
{
if (obstaclePrefabs == null || obstaclePrefabs.Length == 0) return;
// Zufaelliges Hindernis und Spur waehlen
int prefabIndex = Random.Range(0, obstaclePrefabs.Length);
int lane = GetRandomLane();
// Manchmal grosses Hindernis (blockiert 2 Spuren)
if (largObstaclePrefabs != null && largObstaclePrefabs.Length > 0 && Random.value < 0.2f)
{
prefabIndex = Random.Range(0, largObstaclePrefabs.Length);
SpawnObstacleAtLane(largObstaclePrefabs[prefabIndex], parent, zPosition, lane);
}
else
{
SpawnObstacleAtLane(obstaclePrefabs[prefabIndex], parent, zPosition, lane);
}
}
private void SpawnObstacleAtLane(GameObject prefab, Transform parent, float zPosition, int lane)
{
float xPos = (lane - 1) * laneDistance;
Vector3 position = new Vector3(xPos, 0, zPosition);
GameObject obstacle = Instantiate(prefab, parent);
obstacle.transform.localPosition = position;
obstacle.tag = "Obstacle";
}
// ==============================================
// Items
// ==============================================
private void SpawnItem(GameObject prefab, Transform parent, float zPosition, int lane)
{
if (prefab == null) return;
float xPos = (lane - 1) * laneDistance;
Vector3 position = new Vector3(xPos, 0.5f, zPosition); // Leicht ueber dem Boden
GameObject item = Instantiate(prefab, parent);
item.transform.localPosition = position;
}
private void SpawnCoinRow(Transform parent, float zPosition)
{
if (coinPrefab == null) return;
int lane = GetRandomLane();
int coinCount = Random.Range(3, 6);
for (int i = 0; i < coinCount; i++)
{
SpawnItem(coinPrefab, parent, zPosition + (i * 2f), lane);
}
}
// ==============================================
// Quiz-Trigger
// ==============================================
private void SpawnQuizTrigger(Transform parent, float zPosition)
{
GameObject prefab = GetRandomTriggerPrefab();
if (prefab == null) return;
// Trigger sind meist in der Mitte oder ueber der Strasse
Vector3 position = new Vector3(0, 0, zPosition);
GameObject trigger = Instantiate(prefab, parent);
trigger.transform.localPosition = position;
trigger.tag = "QuizTrigger";
// VisualTrigger-Komponente hinzufuegen falls nicht vorhanden
if (trigger.GetComponent<VisualTrigger>() == null)
{
VisualTrigger vt = trigger.AddComponent<VisualTrigger>();
vt.TriggerType = GetTriggerTypeFromPrefab(prefab);
}
}
private GameObject GetRandomTriggerPrefab()
{
GameObject[] triggers = new GameObject[] { bridgePrefab, treePrefab, housePrefab };
var validTriggers = System.Array.FindAll(triggers, t => t != null);
if (validTriggers.Length == 0) return null;
return validTriggers[Random.Range(0, validTriggers.Length)];
}
private string GetTriggerTypeFromPrefab(GameObject prefab)
{
if (prefab == bridgePrefab) return "bridge";
if (prefab == treePrefab) return "tree";
if (prefab == housePrefab) return "house";
return "unknown";
}
// ==============================================
// Helper
// ==============================================
private int GetRandomLane()
{
return Random.Range(0, 3); // 0, 1, oder 2
}
public void ResetSpawner()
{
lastObstacleZ = 0f;
quizTriggerCount = 0;
UpdateFromDifficulty();
}
}
}

View File

@@ -0,0 +1,233 @@
// ==============================================
// TrackGenerator.cs - Endlose Streckengenerierung
// ==============================================
// Erzeugt endlose Streckenabschnitte durch
// Object-Pooling und Recycling.
using System.Collections.Generic;
using UnityEngine;
namespace BreakpilotDrive
{
public class TrackGenerator : MonoBehaviour
{
public static TrackGenerator Instance { get; private set; }
[Header("Track Prefabs")]
[SerializeField] private GameObject[] trackSegmentPrefabs; // Verschiedene Streckenabschnitte
[SerializeField] private GameObject startSegmentPrefab; // Start-Segment
[Header("Generierung")]
[SerializeField] private float segmentLength = 50f; // Laenge eines Segments
[SerializeField] private int visibleSegments = 5; // Anzahl sichtbarer Segmente
[SerializeField] private float despawnDistance = -20f; // Wann Segment recycelt wird
[Header("Bewegung")]
[SerializeField] private float baseSpeed = 10f; // Basis-Geschwindigkeit
private float currentSpeed;
// Object Pool
private List<GameObject> activeSegments = new List<GameObject>();
private Queue<GameObject> segmentPool = new Queue<GameObject>();
// Position
private float nextSpawnZ = 0f;
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
void Start()
{
currentSpeed = baseSpeed;
// Geschwindigkeit von Difficulty laden
if (GameManager.Instance?.DifficultySettings != null)
{
currentSpeed = GameManager.Instance.DifficultySettings.lane_speed;
}
// Initiale Segmente erstellen
InitializeTrack();
}
void Update()
{
if (GameManager.Instance?.CurrentState != GameState.Playing)
return;
// Segmente bewegen
MoveSegments();
// Alte Segmente recyceln
RecycleSegments();
// Neue Segmente spawnen
SpawnSegmentsIfNeeded();
}
// ==============================================
// Initialisierung
// ==============================================
private void InitializeTrack()
{
nextSpawnZ = 0f;
// Start-Segment
if (startSegmentPrefab != null)
{
SpawnSegment(startSegmentPrefab);
}
// Weitere Segmente fuer sichtbaren Bereich
for (int i = 0; i < visibleSegments; i++)
{
SpawnRandomSegment();
}
}
// ==============================================
// Segment-Spawning
// ==============================================
private void SpawnRandomSegment()
{
if (trackSegmentPrefabs == null || trackSegmentPrefabs.Length == 0)
{
Debug.LogWarning("Keine Track-Segment-Prefabs zugewiesen!");
return;
}
int index = Random.Range(0, trackSegmentPrefabs.Length);
SpawnSegment(trackSegmentPrefabs[index]);
}
private void SpawnSegment(GameObject prefab)
{
GameObject segment;
// Aus Pool holen oder neu erstellen
if (segmentPool.Count > 0)
{
segment = segmentPool.Dequeue();
segment.SetActive(true);
}
else
{
segment = Instantiate(prefab, transform);
}
// Position setzen
segment.transform.position = new Vector3(0, 0, nextSpawnZ);
nextSpawnZ += segmentLength;
activeSegments.Add(segment);
// Hindernisse und Items auf Segment spawnen
ObstacleSpawner.Instance?.SpawnOnSegment(segment, nextSpawnZ - segmentLength);
}
private void SpawnSegmentsIfNeeded()
{
// Pruefe ob wir mehr Segmente brauchen
float playerZ = 0; // Spieler ist bei Z=0, Welt bewegt sich
float maxVisibleZ = playerZ + (visibleSegments * segmentLength);
while (nextSpawnZ < maxVisibleZ)
{
SpawnRandomSegment();
}
}
// ==============================================
// Bewegung
// ==============================================
private void MoveSegments()
{
Vector3 movement = Vector3.back * currentSpeed * Time.deltaTime;
foreach (var segment in activeSegments)
{
segment.transform.position += movement;
}
// Auch nextSpawnZ anpassen
nextSpawnZ -= currentSpeed * Time.deltaTime;
}
// ==============================================
// Recycling
// ==============================================
private void RecycleSegments()
{
for (int i = activeSegments.Count - 1; i >= 0; i--)
{
if (activeSegments[i].transform.position.z < despawnDistance)
{
RecycleSegment(activeSegments[i]);
activeSegments.RemoveAt(i);
}
}
}
private void RecycleSegment(GameObject segment)
{
// Kinder-Objekte (Hindernisse, Items) entfernen
foreach (Transform child in segment.transform)
{
if (child.CompareTag("Obstacle") || child.CompareTag("Coin") ||
child.CompareTag("Star") || child.CompareTag("QuizTrigger"))
{
Destroy(child.gameObject);
}
}
// Segment deaktivieren und zurueck in Pool
segment.SetActive(false);
segmentPool.Enqueue(segment);
}
// ==============================================
// Geschwindigkeit
// ==============================================
public void SetSpeed(float speed)
{
currentSpeed = speed;
}
public float GetSpeed()
{
return currentSpeed;
}
public void IncreaseSpeed(float amount)
{
currentSpeed += amount;
}
// ==============================================
// Reset
// ==============================================
public void ResetTrack()
{
// Alle aktiven Segmente recyceln
foreach (var segment in activeSegments)
{
RecycleSegment(segment);
}
activeSegments.Clear();
// Neu initialisieren
nextSpawnZ = 0f;
currentSpeed = baseSpeed;
InitializeTrack();
}
}
}

View File

@@ -0,0 +1,57 @@
// ==============================================
// VisualTrigger.cs - Quiz-Trigger Objekt
// ==============================================
// Loest Quick-Quiz aus wenn Spieler durchfaehrt.
using UnityEngine;
namespace BreakpilotDrive
{
public class VisualTrigger : MonoBehaviour
{
[Header("Trigger-Konfiguration")]
[SerializeField] private string triggerType = "bridge"; // "bridge", "tree", "house"
public string TriggerType
{
get => triggerType;
set => triggerType = value;
}
private bool hasTriggered = false;
void OnTriggerEnter(Collider other)
{
if (hasTriggered) return;
if (other.CompareTag("Player"))
{
hasTriggered = true;
// Quick-Quiz ausloesen
if (QuizManager.Instance != null)
{
QuizManager.Instance.ShowQuickQuestion(triggerType);
}
Debug.Log($"Quiz-Trigger ausgeloest: {triggerType}");
}
}
void OnTriggerExit(Collider other)
{
// Reset fuer naechsten Durchlauf (falls Objekt recycelt wird)
if (other.CompareTag("Player"))
{
// Optional: Trigger nach kurzer Zeit wieder aktivieren
// hasTriggered = false;
}
}
// Wird aufgerufen wenn Objekt recycelt wird
void OnDisable()
{
hasTriggered = false;
}
}
}

View File

@@ -0,0 +1,144 @@
// ==============================================
// GameHUD.cs - Spiel-UI Anzeige
// ==============================================
// Zeigt Score, Leben, Distanz und andere
// Spielinformationen an.
using UnityEngine;
using TMPro;
using UnityEngine.UI;
namespace BreakpilotDrive
{
public class GameHUD : MonoBehaviour
{
[Header("Score")]
[SerializeField] private TextMeshProUGUI scoreText;
[SerializeField] private TextMeshProUGUI highScoreText;
[Header("Leben")]
[SerializeField] private TextMeshProUGUI livesText;
[SerializeField] private Image[] heartImages;
[Header("Distanz")]
[SerializeField] private TextMeshProUGUI distanceText;
[Header("Quiz-Statistik")]
[SerializeField] private TextMeshProUGUI quizStatsText;
[Header("Animationen")]
[SerializeField] private Animator scoreAnimator;
private int displayedScore = 0;
private int targetScore = 0;
void Start()
{
// Events abonnieren
if (GameManager.Instance != null)
{
GameManager.Instance.OnScoreChanged += UpdateScore;
GameManager.Instance.OnLivesChanged += UpdateLives;
GameManager.Instance.OnDistanceChanged += UpdateDistance;
}
// Initial-Werte setzen
UpdateScore(0);
UpdateLives(3);
UpdateDistance(0);
}
void OnDestroy()
{
// Events abbestellen
if (GameManager.Instance != null)
{
GameManager.Instance.OnScoreChanged -= UpdateScore;
GameManager.Instance.OnLivesChanged -= UpdateLives;
GameManager.Instance.OnDistanceChanged -= UpdateDistance;
}
}
void Update()
{
// Score-Animation (zaehlt hoch)
if (displayedScore != targetScore)
{
displayedScore = (int)Mathf.MoveTowards(displayedScore, targetScore, Time.deltaTime * 1000);
if (scoreText != null)
{
scoreText.text = displayedScore.ToString("N0");
}
}
// Quiz-Statistik aktualisieren
UpdateQuizStats();
}
// ==============================================
// Score
// ==============================================
private void UpdateScore(int newScore)
{
targetScore = newScore;
// Animation ausloesen bei Punkte-Gewinn
if (newScore > displayedScore && scoreAnimator != null)
{
scoreAnimator.SetTrigger("ScoreUp");
}
}
// ==============================================
// Leben
// ==============================================
private void UpdateLives(int lives)
{
if (livesText != null)
{
livesText.text = $"x{lives}";
}
// Herz-Icons aktualisieren
if (heartImages != null)
{
for (int i = 0; i < heartImages.Length; i++)
{
heartImages[i].enabled = i < lives;
}
}
}
// ==============================================
// Distanz
// ==============================================
private void UpdateDistance(float distance)
{
if (distanceText != null)
{
distanceText.text = $"{distance:F0}m";
}
}
// ==============================================
// Quiz-Statistik
// ==============================================
private void UpdateQuizStats()
{
if (quizStatsText == null || QuizManager.Instance == null) return;
int answered = QuizManager.Instance.GetQuestionsAnswered();
int correct = QuizManager.Instance.GetQuestionsCorrect();
if (answered > 0)
{
float accuracy = QuizManager.Instance.GetAccuracy() * 100;
quizStatsText.text = $"Quiz: {correct}/{answered} ({accuracy:F0}%)";
}
else
{
quizStatsText.text = "Quiz: 0/0";
}
}
}
}

View File

@@ -0,0 +1,180 @@
// ==============================================
// MainMenu.cs - Hauptmenue Steuerung
// ==============================================
// Verwaltet das Startmenue mit Spielmodus-Auswahl
// und Einstellungen.
using UnityEngine;
using UnityEngine.SceneManagement;
using TMPro;
namespace BreakpilotDrive
{
public class MainMenu : MonoBehaviour
{
[Header("UI Elemente")]
[SerializeField] private GameObject mainPanel;
[SerializeField] private GameObject settingsPanel;
[SerializeField] private GameObject loadingPanel;
[Header("Benutzer-Info")]
[SerializeField] private TMP_InputField userIdInput;
[SerializeField] private TextMeshProUGUI levelText;
[SerializeField] private TextMeshProUGUI welcomeText;
[Header("Scene-Namen")]
[SerializeField] private string videoGameScene = "Game_Video";
[SerializeField] private string audioGameScene = "Game_Audio";
// Benutzer-Daten
private string currentUserId = "guest";
private LearningLevel currentLevel;
void Start()
{
// Panels initialisieren
if (mainPanel) mainPanel.SetActive(true);
if (settingsPanel) settingsPanel.SetActive(false);
if (loadingPanel) loadingPanel.SetActive(false);
// Gespeicherte User-ID laden
currentUserId = PlayerPrefs.GetString("UserId", "guest");
if (userIdInput != null)
{
userIdInput.text = currentUserId;
}
// Lernniveau laden
LoadUserLevel();
}
// ==============================================
// Benutzer-Management
// ==============================================
public void OnUserIdChanged(string newUserId)
{
currentUserId = string.IsNullOrEmpty(newUserId) ? "guest" : newUserId;
PlayerPrefs.SetString("UserId", currentUserId);
PlayerPrefs.Save();
LoadUserLevel();
}
private void LoadUserLevel()
{
if (BreakpilotAPI.Instance != null)
{
StartCoroutine(BreakpilotAPI.Instance.GetLearningLevel(currentUserId,
onSuccess: (level) =>
{
currentLevel = level;
UpdateLevelDisplay();
},
onError: (error) =>
{
Debug.LogWarning($"Lernniveau konnte nicht geladen werden: {error}");
// Fallback-Level
currentLevel = new LearningLevel { overall_level = 3 };
UpdateLevelDisplay();
}
));
}
}
private void UpdateLevelDisplay()
{
if (levelText != null && currentLevel != null)
{
levelText.text = $"Level {currentLevel.overall_level}";
}
if (welcomeText != null)
{
string name = currentUserId == "guest" ? "Gast" : currentUserId;
welcomeText.text = $"Hallo, {name}!";
}
}
// ==============================================
// Spielstart
// ==============================================
public void PlayVideoMode()
{
StartGame(videoGameScene);
}
public void PlayAudioMode()
{
StartGame(audioGameScene);
}
private void StartGame(string sceneName)
{
// Loading anzeigen
if (loadingPanel) loadingPanel.SetActive(true);
if (mainPanel) mainPanel.SetActive(false);
// Fragen vorladen
if (BreakpilotAPI.Instance != null)
{
int difficulty = currentLevel?.overall_level ?? 3;
StartCoroutine(BreakpilotAPI.Instance.GetQuizQuestions(
difficulty: difficulty,
count: 20,
onSuccess: (questions) =>
{
Debug.Log($"{questions.Length} Fragen vorgeladen");
LoadScene(sceneName);
},
onError: (error) =>
{
Debug.LogWarning($"Fragen konnten nicht geladen werden: {error}");
// Trotzdem starten (Offline-Modus)
LoadScene(sceneName);
}
));
}
else
{
LoadScene(sceneName);
}
}
private void LoadScene(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
// ==============================================
// Einstellungen
// ==============================================
public void OpenSettings()
{
if (mainPanel) mainPanel.SetActive(false);
if (settingsPanel) settingsPanel.SetActive(true);
}
public void CloseSettings()
{
if (settingsPanel) settingsPanel.SetActive(false);
if (mainPanel) mainPanel.SetActive(true);
}
// ==============================================
// Sonstiges
// ==============================================
public void QuitGame()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
public void OpenWebsite()
{
Application.OpenURL("https://breakpilot.app");
}
}
}

View File

@@ -0,0 +1,340 @@
// ==============================================
// QuizOverlay.cs - Quiz-UI Anzeige
// ==============================================
// Zeigt Quiz-Fragen an (Quick und Pause Mode)
// mit Timer, Antwort-Buttons und Feedback.
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace BreakpilotDrive
{
public class QuizOverlay : MonoBehaviour
{
[Header("Panels")]
[SerializeField] private GameObject quickQuizPanel;
[SerializeField] private GameObject pauseQuizPanel;
[SerializeField] private GameObject feedbackPanel;
[Header("Quick Quiz UI")]
[SerializeField] private TextMeshProUGUI quickQuestionText;
[SerializeField] private Button[] quickAnswerButtons;
[SerializeField] private Slider timerSlider;
[SerializeField] private TextMeshProUGUI timerText;
[Header("Pause Quiz UI")]
[SerializeField] private TextMeshProUGUI pauseQuestionText;
[SerializeField] private Button[] pauseAnswerButtons;
[SerializeField] private TextMeshProUGUI pauseHintText;
[Header("Feedback UI")]
[SerializeField] private TextMeshProUGUI feedbackText;
[SerializeField] private Image feedbackIcon;
[SerializeField] private Sprite correctIcon;
[SerializeField] private Sprite wrongIcon;
[Header("Farben")]
[SerializeField] private Color normalColor = Color.white;
[SerializeField] private Color correctColor = Color.green;
[SerializeField] private Color wrongColor = Color.red;
[SerializeField] private Color selectedColor = Color.yellow;
[Header("Animation")]
[SerializeField] private Animator quizAnimator;
[SerializeField] private float feedbackDuration = 1.5f;
// Aktuelle Frage
private QuizQuestion currentQuestion;
private int selectedAnswer = -1;
private bool isAnswered = false;
void Start()
{
// Panels verstecken
HideAll();
// QuizManager Events abonnieren
if (QuizManager.Instance != null)
{
QuizManager.Instance.OnQuizStarted += OnQuizStarted;
QuizManager.Instance.OnQuizEnded += OnQuizEnded;
QuizManager.Instance.OnQuestionAnswered += OnQuestionAnswered;
}
// Button-Listener einrichten
SetupButtons();
}
void OnDestroy()
{
if (QuizManager.Instance != null)
{
QuizManager.Instance.OnQuizStarted -= OnQuizStarted;
QuizManager.Instance.OnQuizEnded -= OnQuizEnded;
QuizManager.Instance.OnQuestionAnswered -= OnQuestionAnswered;
}
}
// ==============================================
// Button Setup
// ==============================================
private void SetupButtons()
{
// Quick Answer Buttons
for (int i = 0; i < quickAnswerButtons.Length; i++)
{
int index = i;
quickAnswerButtons[i].onClick.AddListener(() => OnAnswerClicked(index));
}
// Pause Answer Buttons
for (int i = 0; i < pauseAnswerButtons.Length; i++)
{
int index = i;
pauseAnswerButtons[i].onClick.AddListener(() => OnAnswerClicked(index));
}
}
// ==============================================
// Quiz Events
// ==============================================
private void OnQuizStarted()
{
isAnswered = false;
selectedAnswer = -1;
}
private void OnQuizEnded()
{
StartCoroutine(HideAfterDelay(feedbackDuration));
}
private void OnQuestionAnswered(bool correct, int points)
{
isAnswered = true;
ShowFeedback(correct, points);
}
// ==============================================
// Quick Quiz anzeigen
// ==============================================
public void ShowQuickQuiz(QuizQuestion question, float timeLimit)
{
currentQuestion = question;
isAnswered = false;
// Panel aktivieren
HideAll();
if (quickQuizPanel) quickQuizPanel.SetActive(true);
// Frage anzeigen
if (quickQuestionText) quickQuestionText.text = question.question_text;
// Antworten anzeigen
for (int i = 0; i < quickAnswerButtons.Length; i++)
{
if (i < question.options.Length)
{
quickAnswerButtons[i].gameObject.SetActive(true);
var text = quickAnswerButtons[i].GetComponentInChildren<TextMeshProUGUI>();
if (text) text.text = $"[{i + 1}] {question.options[i]}";
ResetButtonColor(quickAnswerButtons[i]);
}
else
{
quickAnswerButtons[i].gameObject.SetActive(false);
}
}
// Timer starten
StartCoroutine(UpdateTimer(timeLimit));
// Animation
if (quizAnimator) quizAnimator.SetTrigger("ShowQuick");
}
private IEnumerator UpdateTimer(float duration)
{
float timeRemaining = duration;
while (timeRemaining > 0 && !isAnswered)
{
timeRemaining -= Time.deltaTime;
// UI aktualisieren
if (timerSlider) timerSlider.value = timeRemaining / duration;
if (timerText) timerText.text = $"{timeRemaining:F1}s";
// Farbe aendern wenn Zeit knapp
if (timerSlider && timeRemaining < duration * 0.3f)
{
timerSlider.fillRect.GetComponent<Image>().color = Color.red;
}
yield return null;
}
}
// ==============================================
// Pause Quiz anzeigen
// ==============================================
public void ShowPauseQuiz(QuizQuestion question)
{
currentQuestion = question;
isAnswered = false;
// Panel aktivieren
HideAll();
if (pauseQuizPanel) pauseQuizPanel.SetActive(true);
// Frage anzeigen
if (pauseQuestionText) pauseQuestionText.text = question.question_text;
// Hint anzeigen (falls aktiviert)
if (pauseHintText)
{
bool hintsEnabled = GameManager.Instance?.DifficultySettings?.hints_enabled ?? false;
pauseHintText.gameObject.SetActive(hintsEnabled);
// TODO: Hint-Text generieren
}
// Antworten anzeigen
for (int i = 0; i < pauseAnswerButtons.Length; i++)
{
if (i < question.options.Length)
{
pauseAnswerButtons[i].gameObject.SetActive(true);
var text = pauseAnswerButtons[i].GetComponentInChildren<TextMeshProUGUI>();
if (text) text.text = $"[{i + 1}] {question.options[i]}";
ResetButtonColor(pauseAnswerButtons[i]);
}
else
{
pauseAnswerButtons[i].gameObject.SetActive(false);
}
}
// Animation
if (quizAnimator) quizAnimator.SetTrigger("ShowPause");
}
// ==============================================
// Antwort-Handling
// ==============================================
private void OnAnswerClicked(int index)
{
if (isAnswered || currentQuestion == null) return;
selectedAnswer = index;
// Button hervorheben
Button[] buttons = currentQuestion.quiz_mode == "quick" ?
quickAnswerButtons : pauseAnswerButtons;
if (index < buttons.Length)
{
HighlightButton(buttons[index]);
}
// An QuizManager melden (wird dort verarbeitet)
// QuizManager ruft dann OnQuestionAnswered auf
}
// ==============================================
// Feedback anzeigen
// ==============================================
private void ShowFeedback(bool correct, int points)
{
// Richtige Antwort markieren
Button[] buttons = currentQuestion?.quiz_mode == "quick" ?
quickAnswerButtons : pauseAnswerButtons;
if (currentQuestion != null && buttons != null)
{
int correctIndex = currentQuestion.correct_index;
if (correctIndex >= 0 && correctIndex < buttons.Length)
{
SetButtonColor(buttons[correctIndex], correctColor);
}
// Falsche Antwort rot markieren
if (!correct && selectedAnswer >= 0 && selectedAnswer < buttons.Length)
{
SetButtonColor(buttons[selectedAnswer], wrongColor);
}
}
// Feedback Panel
if (feedbackPanel)
{
feedbackPanel.SetActive(true);
if (feedbackText)
{
string pointsStr = points >= 0 ? $"+{points}" : $"{points}";
feedbackText.text = correct ?
$"Richtig! {pointsStr} Punkte" :
$"Leider falsch. {pointsStr} Punkte";
feedbackText.color = correct ? correctColor : wrongColor;
}
if (feedbackIcon)
{
feedbackIcon.sprite = correct ? correctIcon : wrongIcon;
}
}
// Sound abspielen
if (correct)
{
AudioManager.Instance?.PlayCorrectSound();
}
else
{
AudioManager.Instance?.PlayWrongSound();
}
// Animation
if (quizAnimator)
{
quizAnimator.SetTrigger(correct ? "Correct" : "Wrong");
}
}
// ==============================================
// Helper
// ==============================================
private void HideAll()
{
if (quickQuizPanel) quickQuizPanel.SetActive(false);
if (pauseQuizPanel) pauseQuizPanel.SetActive(false);
if (feedbackPanel) feedbackPanel.SetActive(false);
}
private IEnumerator HideAfterDelay(float delay)
{
yield return new WaitForSecondsRealtime(delay);
HideAll();
}
private void ResetButtonColor(Button button)
{
SetButtonColor(button, normalColor);
}
private void HighlightButton(Button button)
{
SetButtonColor(button, selectedColor);
}
private void SetButtonColor(Button button, Color color)
{
var colors = button.colors;
colors.normalColor = color;
colors.highlightedColor = color;
button.colors = colors;
}
}
}

View File

@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{{ PRODUCT_NAME }}}</title>
<!-- PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#1a1a2e">
<!-- Favicon -->
<link rel="shortcut icon" href="TemplateData/favicon.ico">
<link rel="apple-touch-icon" href="TemplateData/icon-192.png">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a2e;
}
#unity-container {
position: absolute;
width: 100%;
height: 100%;
}
#unity-canvas {
width: 100%;
height: 100%;
background: #1a1a2e;
}
#unity-loading-bar {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 400px;
}
#unity-logo {
text-align: center;
margin-bottom: 20px;
}
#unity-logo img {
max-width: 200px;
}
#unity-title {
color: #fff;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 24px;
text-align: center;
margin-bottom: 20px;
}
#unity-progress-bar-empty {
width: 100%;
height: 18px;
background: #333;
border-radius: 9px;
overflow: hidden;
}
#unity-progress-bar-full {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
transition: width 0.3s ease;
}
#unity-loading-text {
color: #aaa;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 14px;
text-align: center;
margin-top: 10px;
}
#unity-warning {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: #fff;
padding: 20px;
border-radius: 10px;
text-align: center;
display: none;
}
#unity-footer {
position: absolute;
bottom: 10px;
width: 100%;
text-align: center;
}
#unity-fullscreen-button {
background: transparent;
border: none;
cursor: pointer;
padding: 10px;
}
#unity-fullscreen-button img {
width: 38px;
height: 38px;
opacity: 0.7;
transition: opacity 0.3s;
}
#unity-fullscreen-button:hover img {
opacity: 1;
}
.hidden {
display: none !important;
}
/* Mobile Optimierungen */
@media (max-width: 768px) {
#unity-title {
font-size: 18px;
}
#unity-loading-bar {
width: 90%;
}
}
</style>
</head>
<body>
<div id="unity-container">
<!-- Loading Screen -->
<div id="unity-loading-bar">
<div id="unity-logo">
<img src="TemplateData/logo.png" alt="Breakpilot Drive">
</div>
<div id="unity-title">{{{ PRODUCT_NAME }}}</div>
<div id="unity-progress-bar-empty">
<div id="unity-progress-bar-full"></div>
</div>
<div id="unity-loading-text">Lade...</div>
</div>
<!-- Game Canvas -->
<canvas id="unity-canvas" tabindex="-1"></canvas>
<!-- Warning Message -->
<div id="unity-warning"></div>
<!-- Footer -->
<div id="unity-footer">
<button id="unity-fullscreen-button" title="Vollbild">
<img src="TemplateData/fullscreen-button.png" alt="Vollbild">
</button>
</div>
</div>
<script>
var container = document.querySelector("#unity-container");
var canvas = document.querySelector("#unity-canvas");
var loadingBar = document.querySelector("#unity-loading-bar");
var progressBarFull = document.querySelector("#unity-progress-bar-full");
var loadingText = document.querySelector("#unity-loading-text");
var fullscreenButton = document.querySelector("#unity-fullscreen-button");
var warningBanner = document.querySelector("#unity-warning");
// Zeigt Warnungen/Fehler an
function unityShowBanner(msg, type) {
function updateBannerVisibility() {
warningBanner.style.display = warningBanner.children.length ? 'block' : 'none';
}
var div = document.createElement('div');
div.innerHTML = msg;
warningBanner.appendChild(div);
if (type == 'error') div.style = 'color: #ff0000;';
else if (type == 'warning') div.style = 'color: #ff9900;';
setTimeout(function() {
warningBanner.removeChild(div);
updateBannerVisibility();
}, 5000);
updateBannerVisibility();
}
// Build-Konfiguration
var buildUrl = "Build";
var loaderUrl = buildUrl + "/{{{ LOADER_FILENAME }}}";
var config = {
dataUrl: buildUrl + "/{{{ DATA_FILENAME }}}",
frameworkUrl: buildUrl + "/{{{ FRAMEWORK_FILENAME }}}",
#if USE_WASM
codeUrl: buildUrl + "/{{{ CODE_FILENAME }}}",
#endif
#if MEMORY_FILENAME
memoryUrl: buildUrl + "/{{{ MEMORY_FILENAME }}}",
#endif
#if SYMBOLS_FILENAME
symbolsUrl: buildUrl + "/{{{ SYMBOLS_FILENAME }}}",
#endif
streamingAssetsUrl: "StreamingAssets",
companyName: "{{{ COMPANY_NAME }}}",
productName: "{{{ PRODUCT_NAME }}}",
productVersion: "{{{ PRODUCT_VERSION }}}",
showBanner: unityShowBanner,
};
// Mobile Check
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
document.getElementsByTagName('head')[0].appendChild(meta);
container.className = "unity-mobile";
}
// Canvas-Groesse anpassen
canvas.style.width = "100%";
canvas.style.height = "100%";
// Loader-Script laden
var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {
// Progress-Bar aktualisieren
var percent = Math.round(progress * 100);
progressBarFull.style.width = percent + "%";
loadingText.textContent = "Lade... " + percent + "%";
}).then((unityInstance) => {
// Loading fertig
loadingBar.classList.add("hidden");
// Fullscreen-Button
fullscreenButton.onclick = () => {
unityInstance.SetFullscreen(1);
};
// Unity-Instanz global verfuegbar machen
window.unityInstance = unityInstance;
}).catch((message) => {
alert(message);
});
};
document.body.appendChild(script);
// Text-to-Speech Funktionen (fuer Unity)
window.SpeakWebGL = function(text) {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
var utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'de-DE';
utterance.rate = 0.9;
var voices = window.speechSynthesis.getVoices();
var germanVoice = voices.find(v => v.lang.startsWith('de'));
if (germanVoice) utterance.voice = germanVoice;
window.speechSynthesis.speak(utterance);
}
};
window.SpeakDelayedWebGL = function(text, delay) {
setTimeout(function() {
window.SpeakWebGL(text);
}, delay * 1000);
};
window.StopSpeakingWebGL = function() {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
};
// Voice-Liste laden (manche Browser laden sie async)
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = function() {
console.log('Voices geladen:', window.speechSynthesis.getVoices().length);
};
}
</script>
</body>
</html>

239
breakpilot-drive/index.html Normal file
View File

@@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Breakpilot Drive - Lernspiel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: white;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
max-width: 800px;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(45deg, #e94560, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 1.5rem;
color: #8892b0;
margin-bottom: 2rem;
}
.status-box {
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
margin: 2rem 0;
backdrop-filter: blur(10px);
}
.status-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.mode-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.mode-btn {
padding: 1rem 2rem;
font-size: 1.2rem;
border: none;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
font-weight: 600;
}
.mode-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.mode-btn.video {
background: linear-gradient(45deg, #e94560, #ff6b6b);
color: white;
}
.mode-btn.audio {
background: linear-gradient(45deg, #00d9ff, #00ff88);
color: #1a1a2e;
}
.mode-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.info-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.info-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
text-align: left;
}
.info-card h3 {
color: #e94560;
margin-bottom: 0.5rem;
}
.info-card p {
color: #8892b0;
font-size: 0.9rem;
line-height: 1.5;
}
.api-status {
margin-top: 2rem;
padding: 1rem;
background: rgba(0, 255, 136, 0.1);
border-radius: 8px;
font-size: 0.9rem;
}
.api-status.error {
background: rgba(233, 69, 96, 0.1);
}
.loading {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
footer {
margin-top: 3rem;
color: #8892b0;
font-size: 0.8rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Breakpilot Drive</h1>
<p class="subtitle">Lernspiel fuer Klasse 2-6</p>
<div class="status-box">
<div class="status-icon">🚧</div>
<h2>Unity Build in Entwicklung</h2>
<p style="color: #8892b0; margin-top: 1rem;">
Diese Seite wird durch den Unity WebGL Build ersetzt,<br>
sobald das Spiel fertig ist.
</p>
<div class="mode-buttons">
<button class="mode-btn video" disabled>
🎮 Video-Version
</button>
<button class="mode-btn audio" disabled>
🔊 Audio-Version
</button>
</div>
</div>
<div class="info-cards">
<div class="info-card">
<h3>🚗 Endless Runner</h3>
<p>Steuere dein Auto durch 3 Spuren, weiche Hindernissen aus und sammle Punkte!</p>
</div>
<div class="info-card">
<h3>📚 Quiz-Integration</h3>
<p>Beantworte Fragen waehrend der Fahrt oder nimm dir Zeit bei Denkaufgaben.</p>
</div>
<div class="info-card">
<h3>📈 Adaptiv</h3>
<p>Die Schwierigkeit passt sich automatisch an dein Breakpilot-Lernniveau an.</p>
</div>
<div class="info-card">
<h3>🔊 Barrierefrei</h3>
<p>Audio-Version fuer Hoerspiel-Modus mit raeumlichen Sound-Cues.</p>
</div>
</div>
<div id="api-status" class="api-status loading">
Pruefe Backend-Verbindung...
</div>
<footer>
<p>Breakpilot Drive &copy; 2026 - Lernen mit Spass</p>
</footer>
</div>
<script>
// API Health-Check
async function checkApi() {
const statusEl = document.getElementById('api-status');
try {
// Versuche Backend zu erreichen
const response = await fetch('/api/game/health');
const data = await response.json();
if (data.status === 'healthy') {
statusEl.innerHTML = `✅ Backend verbunden - ${data.questions_available} Quiz-Fragen verfuegbar`;
statusEl.classList.remove('loading', 'error');
} else {
throw new Error('Unhealthy');
}
} catch (e) {
// Fallback: Versuche direkten Backend-Aufruf
try {
const response = await fetch('http://localhost:8000/api/game/health');
const data = await response.json();
statusEl.innerHTML = `✅ Backend (localhost:8000) - ${data.questions_available} Quiz-Fragen`;
statusEl.classList.remove('loading', 'error');
} catch (e2) {
statusEl.innerHTML = '⚠️ Backend nicht erreichbar - Starte docker-compose up';
statusEl.classList.remove('loading');
statusEl.classList.add('error');
}
}
}
// Beim Laden pruefen
checkApi();
// Alle 30 Sekunden erneut pruefen
setInterval(checkApi, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,76 @@
# ==============================================
# Nginx Konfiguration fuer Unity WebGL
# ==============================================
# Optimiert fuer grosse WASM-Dateien und
# korrektes CORS fuer API-Aufrufe
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Standard MIME-Types einbinden + WebGL-spezifische hinzufuegen
include /etc/nginx/mime.types;
# WebGL-spezifische MIME-Types (zusaetzlich)
types {
application/wasm wasm;
}
# Gzip Kompression fuer grosse Dateien
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
application/javascript
application/wasm
application/json
text/plain
text/css
text/html;
gzip_min_length 1000;
# Brotli-komprimierte Unity-Dateien (falls vorhanden)
location ~ \.br$ {
gzip off;
add_header Content-Encoding br;
default_type application/octet-stream;
}
# Gzip-komprimierte Unity-Dateien
location ~ \.gz$ {
gzip off;
add_header Content-Encoding gzip;
default_type application/octet-stream;
}
# Caching fuer statische Assets (1 Jahr)
location ~* \.(data|wasm|js|css|png|jpg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
# Health-Check Endpunkt
location = /health {
default_type application/json;
alias /usr/share/nginx/html/health.json;
}
location = /health.json {
access_log off;
default_type application/json;
}
# SPA Routing - alle Requests auf index.html
location / {
# CORS Headers
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
try_files $uri $uri/ /index.html;
}
}