Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp

- PHP/MySQL Backend (XAMPP + Produktionsserver)
- Front-Controller, API-Endpunkte, Session-Management
- Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss)
- Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem)
- Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst
- persistence.ts: Dualer Speicher (localStorage + Server-API)
- 6 Unit-Test-Dateien fuer bestehende Simulationen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:43:42 +02:00
commit f22c5ebbfe
83 changed files with 22709 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
App/node_modules/
App/dist/
App/.env.local
App/.env.production
App/Don_t_Deploy/.env
.claude/
+594
View File
@@ -0,0 +1,594 @@
Pflichtenheft
Simulation „Flussmanagement“ für den Geografieunterricht
1. Ziel des Projekts
Ziel ist die Entwicklung einer interaktiven Simulation für den Geografieunterricht (Sekundarstufe I, 1015 Jahre), in der Schülerinnen und Schüler ein Flusssystem steuern und dabei Zielkonflikte zwischen:
Hochwasserschutz
Landwirtschaft
Ökologie
Wirtschaft
erkennen und ausbalancieren.
Die Simulation soll sich wie ein Spiel anfühlen, aber fachlich korrekt und didaktisch wirksam sein.
2. Zielgruppe
Schülerinnen und Schüler (1015 Jahre)
Lehrpersonen (Geografie, ggf. fächerübergreifend)
Einsatz im Klassenverband (Einzel- oder Gruppenarbeit)
3. Zentrale didaktische Ziele
Die Anwendung muss ermöglichen:
Verständnis von MenschUmwelt-Systemen
Erkennen von Zielkonflikten (Trade-offs)
Einsicht in nichtlineare Zusammenhänge
Bewertung von Eingriffen in Naturräume
Reflexion historischer Entwicklungen (optional)
4. Spielprinzip
Der Spieler übernimmt die Rolle eines Entscheidungsträgers (z. B. Region/Behörde) und steuert Maßnahmen an einem Fluss.
Grundmechanik
Spieler wählt Steuermaßnahmen
Simulation berechnet Auswirkungen
Spieler sieht visuelle Veränderungen
Spiel bewertet Ergebnis anhand mehrerer Ziele
5. Spieldauer
Das Spiel muss konfigurierbar sein:
Leveltyp Dauer
Kurzlevel 1015 Minuten
Standardlevel 2030 Minuten
Langlevel 3040 Minuten
Steuerung der Dauer über:
Anzahl der Runden
Anzahl aktiver Parameter
Ereignisdichte
Komplexität der Entscheidungen
6. Spielstruktur
6.1 Rundenbasiertes System
Ein Level besteht aus mehreren Runden:
Situation anzeigen
Spieler trifft Entscheidungen
Simulation wird berechnet
Ergebnisse werden visualisiert
ggf. Ereignis tritt ein
6.2 Levelstruktur
Levels unterscheiden sich durch:
Rahmenbedingungen
freigeschaltete Maßnahmen
Zielgewichtung
Schwierigkeit
7. Steuergrößen (Spieleraktionen)
Mindestens folgende Maßnahmen müssen implementiert werden:
Flussbegradigung
Dämme bauen
Ausbaggern
Auen freigeben
Renaturierung
Bewässerung
Jede Maßnahme:
hat Intensität (0100)
hat Kosten
hat positive und negative Effekte
8. Zustandsparameter (Simulation)
Alle Werte liegen im Bereich 0100:
Risiken
Lokales Hochwasser
Hochwasser flussabwärts
Erosion
Nutzen
Bodenfruchtbarkeit
Biodiversität
Grundwasser
Nutzbare Fläche
Wirtschaft
9. Rahmenbedingungen
Pro Level definierbar:
Niederschlag
Extremwetter
Gefälle
Bevölkerungsdruck
Budget
Diese sind nicht direkt steuerbar, beeinflussen aber die Simulation.
10. Simulationslogik
10.1 Grundprinzip
Die Simulation basiert auf:
direkten Effekten von Maßnahmen
indirekten Wechselwirkungen zwischen Parametern
nichtlinearen Funktionen
10.2 Anforderungen an die Logik
Die Simulation muss:
nichtlinear sein (keine einfachen +/ Beziehungen)
abnehmenden Nutzen bei steigender Intensität abbilden
überproportionale Nebenwirkungen bei starken Eingriffen erzeugen
Kettenreaktionen ermöglichen
10.3 Beispielhafte Effekte
Begradigung → mehr Fläche, aber mehr Hochwasser flussabwärts
Dämme → lokaler Schutz, aber Nachteile für Landwirtschaft
Renaturierung → ökologische Vorteile, aber weniger Fläche
11. Bewertungssystem
11.1 Zielbereiche
Das Spiel bewertet:
Sicherheit
Ökologie
Landwirtschaft
Wirtschaft
11.2 Mehrzielbewertung
Es gibt keine perfekte Lösung.
Gesamtscore basiert auf gewichteter Kombination:
Score = f(Sicherheit, Ökologie, Landwirtschaft, Wirtschaft)
Gewichtung ist je Level anpassbar.
12. Balancing-Anforderungen
Das System muss sicherstellen:
12.1 Keine dominante Strategie
Keine Maßnahme darf immer optimal sein
12.2 Zielkonflikte
Jede Maßnahme muss mindestens einen Nachteil haben
12.3 Kontextabhängigkeit
Wirkung hängt von Rahmenbedingungen ab
12.4 Extremwerte vermeiden
Maximale Eingriffe müssen Risiken erzeugen
13. Spielmechaniken gegen Fehlverhalten
Muss implementiert werden:
Budgetbegrenzung
Strafsystem für extreme Eingriffe
Katastrophen bei kritischen Zuständen
Bonus für ausgewogene Strategien
14. Visualisierung
Die Simulation muss visuell darstellen:
Hochwasser (Überflutung)
Flussverlauf (gerade vs. mäandrierend)
Vegetation / Biodiversität
Landwirtschaft (Ertrag sichtbar)
Bebauung
Wichtig:
Jede Änderung im Modell muss sichtbar sein.
15. Benutzeroberfläche
Anforderungen
einfache Bedienung (Schüler geeignet)
klare Rückmeldung
visuelle Feedbacks statt Zahlenlast
Parameter optional einblendbar
16. Admin-/Lehrermodus
Muss enthalten:
Auswahl von Levels
Anpassung von:
Budget
Klima
Zielgewichtung
Aktivieren/Deaktivieren von Maßnahmen
Festlegen der Spieldauer
17. Technische Anforderungen
Frontend
Web-basiert (HTML, JS, optional TypeScript)
responsiv
keine Installation nötig
Backend (optional)
für Speicherung von Szenarien
keine Pflicht für Minimalversion
18. Erweiterbarkeit
Das System muss modular aufgebaut sein, damit später ergänzt werden kann:
historische Level
weitere Naturprozesse
andere geografische Themen
19. Test- und Balancing-System
Das System muss ein internes oder separates Modul enthalten, das:
viele Strategien automatisch testet
dominante Strategien erkennt
Extremverhalten identifiziert
Balanceprobleme meldet
20. Abnahmekriterien
Das System gilt als erfolgreich, wenn:
ein Level in 1040 Minuten spielbar ist
mehrere unterschiedliche Strategien zum Erfolg führen
keine einzelne Maßnahme dominiert
Auswirkungen visuell nachvollziehbar sind
Schüler Zielkonflikte erkennen können
21. Anhänge
Anhang A: Datenmodell (JSON)
Anhang B: Beispiel-Simulationslogik
Anhang C: Balancing-Tester
(Diese basieren auf den zuvor entwickelten Modellen und werden als Referenz bereitgestellt.)
Fazit
Dieses Pflichtenheft beschreibt kein einfaches Spiel, sondern ein:
dynamisches, nichtlineares, mehrzieliges Simulationssystem für didaktisches Lernen
Wenn du willst, gehe ich im nächsten Schritt noch weiter und erstelle dir:
konkrete Level (z. B. Nildelta, Industrialisierung, Alpenfluss)
oder eine fertige Prompt-Version für eine Programmier-KI (z. B. Cursor, GPT, etc.)
Das wäre der nächste logische Schritt.
Ja mach das mit dem konkreten Level noch als Ergänzung
Gut — ich ergänze das Pflichtenheft jetzt um konkrete, sofort implementierbare Level-Spezifikationen.
Diese sind so formuliert, dass eine KI sie direkt in Datenstrukturen oder Code übersetzen kann.
Ergänzung zum Pflichtenheft: Konkrete Leveldefinitionen
22. Levelsystem Struktur
Jedes Level wird formal definiert durch:
type LevelDefinition = {
id: string;
title: string;
description: string;
durationTargetMinutes: number;
initialState: State;
conditions: Conditions;
allowedControls: (keyof Controls)[];
controlLimits: Partial<Controls>;
scoreWeights: {
safety: number;
ecology: number;
agriculture: number;
economy: number;
};
rounds: number;
events?: LevelEvent[];
winConditions: {
minScore?: number;
targetScores?: Partial<ScoreBreakdown>;
};
loseConditions?: {
maxFloodLocal?: number;
maxFloodDownstream?: number;
minGroundwater?: number;
};
};
23. Level 1 Einstieg: „Fluss und Siedlung“
Ziel
Grundverständnis von Hochwasser und einfachen Maßnahmen
const level1: LevelDefinition = {
id: "L1",
title: "Fluss und Siedlung",
description: "Eine kleine Siedlung liegt an einem Fluss und ist regelmäßig von Hochwasser betroffen.",
durationTargetMinutes: 10,
rounds: 5,
initialState: {
floodLocal: 60,
floodDownstream: 40,
erosion: 30,
soilFertility: 60,
biodiversity: 70,
groundwater: 55,
usableLand: 40,
economy: 40
},
conditions: {
rainfall: 60,
extremeWeather: 30,
slope: 40,
populationPressure: 30,
budget: 120
},
allowedControls: ["levees", "floodplainRelease"],
controlLimits: {
levees: 60,
floodplainRelease: 60
},
scoreWeights: {
safety: 0.5,
ecology: 0.2,
agriculture: 0.15,
economy: 0.15
},
winConditions: {
minScore: 60
},
loseConditions: {
maxFloodLocal: 85
}
};
24. Level 2 Landwirtschaft: „Fruchtbares Tal“
Fokus
Nutzen von Überschwemmungen vs. Risiko
const level2: LevelDefinition = {
id: "L2",
title: "Fruchtbares Tal",
description: "Ein Tal lebt von fruchtbaren Böden durch regelmäßige Überschwemmungen.",
durationTargetMinutes: 20,
rounds: 7,
initialState: {
floodLocal: 55,
floodDownstream: 35,
erosion: 35,
soilFertility: 75,
biodiversity: 65,
groundwater: 60,
usableLand: 45,
economy: 50
},
conditions: {
rainfall: 65,
extremeWeather: 40,
slope: 35,
populationPressure: 40,
budget: 150
},
allowedControls: ["levees", "floodplainRelease", "irrigation"],
scoreWeights: {
safety: 0.25,
ecology: 0.2,
agriculture: 0.4,
economy: 0.15
},
winConditions: {
targetScores: {
agriculture: 65,
safety: 50
}
}
};
25. Level 3 Industrialisierung: „Der gezähmte Fluss“
Fokus
Technische Eingriffe und ihre Folgen
const level3: LevelDefinition = {
id: "L3",
title: "Der gezähmte Fluss",
description: "Der Fluss soll kontrolliert werden, um Städte und Industrie zu schützen.",
durationTargetMinutes: 25,
rounds: 8,
initialState: {
floodLocal: 50,
floodDownstream: 45,
erosion: 40,
soilFertility: 55,
biodiversity: 50,
groundwater: 50,
usableLand: 55,
economy: 60
},
conditions: {
rainfall: 60,
extremeWeather: 45,
slope: 50,
populationPressure: 70,
budget: 180
},
allowedControls: [
"levees",
"straightening",
"dredging"
],
scoreWeights: {
safety: 0.4,
ecology: 0.1,
agriculture: 0.2,
economy: 0.3
},
events: [
{
round: 4,
type: "flood_event",
intensity: 70
}
],
winConditions: {
minScore: 65
}
};
26. Level 4 Systemdenken: „Fluss im Gleichgewicht“
Fokus
Mehrere Ziele gleichzeitig
const level4: LevelDefinition = {
id: "L4",
title: "Fluss im Gleichgewicht",
description: "Finde eine Balance zwischen Sicherheit, Natur und Nutzung.",
durationTargetMinutes: 30,
rounds: 10,
initialState: {
floodLocal: 55,
floodDownstream: 50,
erosion: 45,
soilFertility: 60,
biodiversity: 60,
groundwater: 50,
usableLand: 50,
economy: 55
},
conditions: {
rainfall: 60,
extremeWeather: 50,
slope: 45,
populationPressure: 60,
budget: 180
},
allowedControls: [
"levees",
"straightening",
"floodplainRelease",
"renaturation",
"irrigation"
],
scoreWeights: {
safety: 0.25,
ecology: 0.25,
agriculture: 0.25,
economy: 0.25
},
winConditions: {
minScore: 70
}
};
27. Level 5 Speziallevel: Nildelta
Bezug zur realen Welt und deinem Wunsch nach nicht einseitigem Denken
Fokus
Überschwemmung als Vorteil
const level5: LevelDefinition = {
id: "L5",
title: "Nildelta",
description: "Die Überschwemmungen bringen fruchtbare Böden aber auch Risiken.",
durationTargetMinutes: 35,
rounds: 10,
initialState: {
floodLocal: 65,
floodDownstream: 40,
erosion: 30,
soilFertility: 85,
biodiversity: 70,
groundwater: 65,
usableLand: 50,
economy: 60
},
conditions: {
rainfall: 40,
extremeWeather: 20,
slope: 20,
populationPressure: 70,
budget: 160
},
allowedControls: [
"levees",
"irrigation",
"floodplainRelease"
],
scoreWeights: {
safety: 0.2,
ecology: 0.2,
agriculture: 0.45,
economy: 0.15
},
winConditions: {
targetScores: {
agriculture: 75
}
},
loseConditions: {
minGroundwater: 25
}
};
28. Ereignissystem (für alle Levels)
type LevelEvent = {
round: number;
type: "flood_event" | "drought" | "economic_boost";
intensity: number;
};
Beispiele:
Hochwasser
Dürre
wirtschaftlicher Boom
29. Didaktische Progression
Level Konzept
1 Ursache-Wirkung
2 Nutzen vs Risiko
3 Technik vs Natur
4 Systemdenken
5 Realwelt + Transfer
30. Abschluss
Mit dieser Ergänzung hat deine KI jetzt:
ein vollständiges Spielsystem
ein Simulationsmodell
ein Balancing-Konzept
konkrete Leveldaten
klare didaktische Struktur
+14
View File
@@ -0,0 +1,14 @@
Pflichtenheft — Lernsimulation „Stadt & Raumplanung"
(Gespeichert aus User-Input, 2026-04-13)
Siehe Konversation fuer vollstaendiges Pflichtenheft.
Kernpunkte:
- Kachelbasierte Stadtsimulation, 2D Top-Down oder isometrisch
- Strassen mit Anschlusslogik (N/E/S/W Connections)
- Kacheltypen: Strasse, Wohngebiet, Geschaeft, Schule, Park, Industrie, Energie
- Zustandsparameter: traffic, environment, economy, satisfaction, accessibility
- Nichtlineare Simulation mit Zielkonflikten
- Runden- oder Echtzeit-basiert
- Lehrkraefte-Modul mit Vergleichsansicht
- Balancing: keine dominante Strategie
- 10-40 Minuten Spieldauer
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
# GeoGraSim — Environment Configuration
DB_HOST=localhost
DB_NAME=geograsim
DB_USER=root
DB_PASS=
DB_PORT=3306
+18
View File
@@ -0,0 +1,18 @@
RewriteEngine On
RewriteBase /geograsim/App/
# Statische Dateien direkt ausliefern
RewriteRule ^dist/ - [L]
RewriteRule ^assets/ - [L]
RewriteRule ^node_modules/ - [L]
RewriteRule ^src/ - [L]
# Bestehende Dateien/Ordner direkt ausliefern
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule . - [L]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule . - [L]
# Alles andere -> Front Controller
RewriteRule ^(.*)$ index.php [QSA,L]
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Lehrkräfte-Dashboard</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --white: #fff; --text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
}
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.55; }
.nav { padding:.7rem 2rem; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid rgba(0,0,0,.04); background:var(--white); }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:1rem; color:var(--fjord); text-decoration:none; }
.nav-brand img { width:28px; height:28px; border-radius:6px; }
.nav-user { display:flex; align-items:center; gap:.6rem; font-size:.85rem; }
.avatar { width:32px; height:32px; border-radius:50%; background:var(--fjord); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:.78rem; }
.layout { display:grid; grid-template-columns:240px 1fr; min-height:calc(100vh - 60px); }
.sidebar { background:var(--white); border-right:1px solid rgba(0,0,0,.05); padding:1.2rem 1rem; }
.sidebar h4 { font-size:.65rem; font-weight:700; color:var(--text3); text-transform:uppercase; letter-spacing:.08em; margin:1rem 0 .4rem; }
.sidebar h4:first-child { margin-top:0; }
.sidebar a { display:block; padding:.4rem .7rem; color:var(--text2); text-decoration:none; border-radius:8px; font-size:.82rem; font-weight:500; margin-bottom:1px; transition:all .2s; }
.sidebar a:hover, .sidebar a.active { background:var(--fjord-l); color:var(--fjord); }
.main { padding:1.5rem 2rem; }
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:1.2rem; }
.page-header h1 { font-size:1.6rem; font-weight:800; letter-spacing:-.02em; }
.page-header p { color:var(--text3); font-size:.85rem; }
.btn { display:inline-flex; align-items:center; gap:.4rem; padding:.5rem 1.1rem; border-radius:10px; font-weight:600; font-size:.82rem; border:none; cursor:pointer; transition:all .2s; text-decoration:none; }
.btn-p { background:var(--fjord); color:#fff; }
.btn-p:hover { background:var(--fjord-d); }
.btn-o { background:var(--white); color:var(--fjord); border:1.5px solid rgba(74,124,138,.3); }
.grid { display:grid; gap:1rem; }
.grid-3 { grid-template-columns:repeat(3,1fr); }
.grid-2 { grid-template-columns:1fr 1fr; }
.grid-4 { grid-template-columns:repeat(4,1fr); }
.card { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.04); padding:1.1rem; box-shadow:0 2px 10px rgba(0,0,0,.03); }
.card h3 { font-size:.78rem; font-weight:700; color:var(--text3); text-transform:uppercase; letter-spacing:.06em; margin-bottom:.6rem; }
.card-big-num { font-size:2rem; font-weight:900; letter-spacing:-.03em; }
.card-trend { font-size:.75rem; color:var(--moss); font-weight:600; margin-top:.2rem; }
/* Class table */
.classes-table { width:100%; border-collapse:collapse; font-size:.85rem; }
.classes-table th, .classes-table td { padding:.6rem .8rem; text-align:left; border-bottom:1px solid rgba(0,0,0,.05); }
.classes-table th { font-size:.7rem; font-weight:700; color:var(--text3); text-transform:uppercase; letter-spacing:.06em; }
.classes-table tr:hover { background:#fafaf8; }
.student-row { display:flex; align-items:center; gap:.5rem; }
.progress-bar { width:80px; height:6px; background:#e8e5dc; border-radius:3px; overflow:hidden; }
.progress-fill { height:100%; border-radius:3px; }
.badge { display:inline-block; padding:2px 9px; border-radius:6px; font-size:.68rem; font-weight:600; }
.b-on { background:var(--moss-l); color:var(--moss); }
.b-off { background:#f0eeea; color:var(--text3); }
.b-progress { background:var(--fjord-l); color:var(--fjord); }
.b-warn { background:var(--sand-l); color:var(--sand); }
/* Sim card with toggle */
.sim-toggle-card { display:flex; align-items:center; gap:.8rem; padding:.8rem; border-radius:10px; background:#fafaf8; border:1px solid rgba(0,0,0,.04); margin-bottom:.5rem; }
.sim-toggle-card-icon { font-size:1.5rem; }
.sim-toggle-card-info { flex:1; }
.sim-toggle-card-info h5 { font-size:.85rem; font-weight:700; }
.sim-toggle-card-info p { font-size:.72rem; color:var(--text3); }
.toggle { width:36px; height:20px; background:#d4d4d0; border-radius:10px; position:relative; cursor:pointer; transition:all .2s; }
.toggle.on { background:var(--moss); }
.toggle::after { content:''; position:absolute; top:2px; left:2px; width:16px; height:16px; background:#fff; border-radius:50%; transition:all .2s; box-shadow:0 1px 3px rgba(0,0,0,.2); }
.toggle.on::after { left:18px; }
.alert { background:var(--coral-l); border-left:3px solid var(--coral); padding:.6rem .8rem; border-radius:8px; font-size:.78rem; margin-bottom:.5rem; }
.alert strong { color:var(--coral); }
.demo-banner { background:var(--sand-l); color:var(--sand); padding:.5rem 1rem; text-align:center; font-size:.78rem; font-weight:600; }
</style>
</head>
<body>
<div class="demo-banner">⚠️ Demo-Mockup — alle Daten sind erfunden, kein Backend angeschlossen</div>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim · Dashboard</a>
<div class="nav-user">
<span style="color:var(--text2)">Mag. Schroffenegger</span>
<div class="avatar">TS</div>
</div>
</nav>
<div class="layout">
<aside class="sidebar">
<h4>Übersicht</h4>
<a href="#" class="active">📊 Dashboard</a>
<a href="#">👥 Klassen</a>
<a href="#">🎯 Simulationen</a>
<a href="#">📈 Berichte</a>
<h4>Meine Klassen</h4>
<a href="#">1A — 24 Schüler*innen</a>
<a href="#">1B — 22 Schüler*innen</a>
<a href="#">2A — 25 Schüler*innen</a>
<a href="#">3B — 23 Schüler*innen</a>
<h4>Tools</h4>
<a href="#">🗳️ Live-Abstimmung</a>
<a href="#">📥 Export (PDF)</a>
<a href="#">⚙️ Einstellungen</a>
</aside>
<main class="main">
<div class="page-header">
<div>
<h1>Klasse 1A — Übersicht</h1>
<p>24 Schüler*innen · Letzte Aktivität: vor 2 Stunden</p>
</div>
<div style="display:flex;gap:.5rem">
<button class="btn btn-o">📥 Bericht exportieren</button>
<button class="btn btn-p">🚀 Simulation starten</button>
</div>
</div>
<!-- Top stats row -->
<div class="grid grid-4" style="margin-bottom:1rem">
<div class="card">
<h3>Aktive Schüler*innen</h3>
<div class="card-big-num" style="color:var(--fjord)">18 / 24</div>
<div class="card-trend">↗ +3 seit gestern</div>
</div>
<div class="card">
<h3>Abgeschlossene Simulationen</h3>
<div class="card-big-num" style="color:var(--moss)">142</div>
<div class="card-trend">↗ 87% Erfolgsquote</div>
</div>
<div class="card">
<h3>Durchschn. Lernzeit</h3>
<div class="card-big-num" style="color:var(--sand)">23 min</div>
<div class="card-trend" style="color:var(--text3)">pro Simulation</div>
</div>
<div class="card">
<h3>Identifizierte Fehlkonzepte</h3>
<div class="card-big-num" style="color:var(--coral)">5</div>
<div class="card-trend" style="color:var(--coral)">⚠ Aufmerksamkeit nötig</div>
</div>
</div>
<!-- Two column section -->
<div class="grid grid-2" style="margin-bottom:1rem">
<!-- Active simulation -->
<div class="card">
<h3>🔴 Live: Treibhauseffekt-Simulator</h3>
<p style="font-size:.85rem;margin:.5rem 0 .8rem;color:var(--text2)">12 von 24 Schüler*innen bearbeiten gerade die Simulation.</p>
<table class="classes-table">
<thead>
<tr><th>Schüler*in</th><th>Phase</th><th>Fortschritt</th><th>Zeit</th></tr>
</thead>
<tbody>
<tr>
<td><div class="student-row">👤 Anna B.</div></td>
<td><span class="badge b-progress">Observe</span></td>
<td><div class="progress-bar"><div class="progress-fill" style="width:75%;background:var(--fjord)"></div></div></td>
<td>14:32</td>
</tr>
<tr>
<td><div class="student-row">👤 Lukas F.</div></td>
<td><span class="badge b-on">Reflect</span></td>
<td><div class="progress-bar"><div class="progress-fill" style="width:90%;background:var(--moss)"></div></div></td>
<td>18:45</td>
</tr>
<tr>
<td><div class="student-row">👤 Sara M.</div></td>
<td><span class="badge b-progress">Predict</span></td>
<td><div class="progress-bar"><div class="progress-fill" style="width:30%;background:var(--fjord)"></div></div></td>
<td>05:12</td>
</tr>
<tr>
<td><div class="student-row">👤 Tim H.</div></td>
<td><span class="badge b-warn">Pausiert</span></td>
<td><div class="progress-bar"><div class="progress-fill" style="width:55%;background:var(--sand)"></div></div></td>
<td>09:20</td>
</tr>
<tr>
<td><div class="student-row">👤 Emma K.</div></td>
<td><span class="badge b-on">Reflect</span></td>
<td><div class="progress-bar"><div class="progress-fill" style="width:95%;background:var(--moss)"></div></div></td>
<td>21:08</td>
</tr>
</tbody>
</table>
</div>
<!-- Insights -->
<div class="card">
<h3>💡 Erkannte Fehlkonzepte</h3>
<div class="alert">
<strong>Ozonloch ≠ Treibhauseffekt</strong><br>
7 Schüler*innen haben in ihrer Vorhersage erwähnt, dass das "Ozonloch" für die Erwärmung verantwortlich sei.
→ Empfehlung: Im Plenum klarstellen.
</div>
<div class="alert">
<strong>Lineare statt logarithmische Beziehung</strong><br>
5 Schüler*innen haben "doppelt so viel CO₂ = doppelt so warm" angenommen.
→ Empfehlung: Klimasensitivität gemeinsam besprechen.
</div>
<div class="alert">
<strong>Albedo nicht verstanden</strong><br>
3 Schüler*innen haben Albedo nicht verändert. → Optionale Vertiefung anbieten.
</div>
<h3 style="margin-top:1rem">🎯 Lernziele-Erreichung (Klasse)</h3>
<div style="font-size:.78rem;margin-top:.4rem">
<div style="display:flex;justify-content:space-between;margin-bottom:.3rem"><span>Treibhauseffekt verstehen</span><strong>83%</strong></div>
<div class="progress-bar" style="width:100%"><div class="progress-fill" style="width:83%;background:var(--moss)"></div></div>
<div style="display:flex;justify-content:space-between;margin:.6rem 0 .3rem"><span>CO₂-Temperatur-Zusammenhang</span><strong>71%</strong></div>
<div class="progress-bar" style="width:100%"><div class="progress-fill" style="width:71%;background:var(--fjord)"></div></div>
<div style="display:flex;justify-content:space-between;margin:.6rem 0 .3rem"><span>Abgrenzung zum Ozonloch</span><strong>54%</strong></div>
<div class="progress-bar" style="width:100%"><div class="progress-fill" style="width:54%;background:var(--sand)"></div></div>
</div>
</div>
</div>
<!-- Module freischaltung -->
<div class="card" style="margin-bottom:1rem">
<h3>⚙️ Module für Klasse 1A freigeschaltet</h3>
<div class="grid grid-2" style="margin-top:.8rem">
<div class="sim-toggle-card">
<div class="sim-toggle-card-icon">🌡️</div>
<div class="sim-toggle-card-info">
<h5>Treibhauseffekt-Simulator</h5>
<p>1. Klasse · 20 min · Schwierigkeit: Standard</p>
</div>
<div class="toggle on"></div>
</div>
<div class="sim-toggle-card">
<div class="sim-toggle-card-icon">🌋</div>
<div class="sim-toggle-card-info">
<h5>Erdbeben-Simulator</h5>
<p>1. Klasse · 25 min · Schwierigkeit: Standard</p>
</div>
<div class="toggle on"></div>
</div>
<div class="sim-toggle-card">
<div class="sim-toggle-card-icon"></div>
<div class="sim-toggle-card-info">
<h5>Energiemix-Simulator</h5>
<p>2. Klasse · 25 min · Erst nächstes Jahr</p>
</div>
<div class="toggle"></div>
</div>
<div class="sim-toggle-card">
<div class="sim-toggle-card-icon">🏗️</div>
<div class="sim-toggle-card-info">
<h5>Raumplanung Gemeinde</h5>
<p>3. Klasse · 45 min · Erst in 2 Jahren</p>
</div>
<div class="toggle"></div>
</div>
</div>
</div>
<!-- Live abstimmung -->
<div class="card">
<h3>🗳️ Live-Abstimmung starten</h3>
<p style="font-size:.85rem;color:var(--text2);margin:.4rem 0 .8rem">Stelle deiner Klasse eine Frage. Das Ergebnis fließt direkt in die laufende Simulation ein.</p>
<div style="display:flex;gap:.5rem">
<input type="text" placeholder="z.B.: Sollten wir die CO₂-Werte von 2050 simulieren?" style="flex:1;padding:.6rem 1rem;border:1px solid rgba(0,0,0,.1);border-radius:10px;font-family:inherit;font-size:.85rem">
<button class="btn btn-p">Abstimmung starten</button>
</div>
</div>
</main>
</div>
<script>
// Toggle functionality
document.querySelectorAll('.toggle').forEach(t => {
t.addEventListener('click', () => t.classList.toggle('on'))
})
</script>
</body>
</html>
+239
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">
<title>GeoGraSim — Energiewende-Planer*in</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --bg2: #efeee9; --white: #fff;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
--danger: #b04a3a;
}
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; }
.nav { padding:.6rem 1.2rem; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid rgba(0,0,0,.05); background:var(--white); }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:1rem; color:var(--fjord); text-decoration:none; }
.nav-brand img { width:28px; height:28px; border-radius:6px; }
.nav-actions { display:flex; gap:.4rem; }
.nav-btn { padding:.4rem .8rem; border:1px solid rgba(0,0,0,.08); border-radius:8px; background:var(--white); color:var(--text2); font-size:.78rem; font-weight:600; cursor:pointer; text-decoration:none; }
.nav-btn:hover { background:var(--fjord-l); border-color:var(--fjord); color:var(--fjord); }
.game-layout { display:grid; grid-template-columns:280px 1fr 300px; gap:.8rem; padding:.8rem; max-width:1400px; margin:0 auto; align-items:start; }
.panel { background:var(--white); border-radius:12px; border:1px solid rgba(0,0,0,.05); padding:.9rem; box-shadow:0 2px 8px rgba(0,0,0,.03); }
.panel-h { font-size:.66rem; font-weight:700; color:var(--fjord); text-transform:uppercase; letter-spacing:.06em; margin-bottom:.5rem; }
.left { display:flex; flex-direction:column; gap:.8rem; }
.resource { display:flex; align-items:center; gap:.6rem; padding:.4rem .2rem; border-bottom:1px solid rgba(0,0,0,.04); }
.resource:last-child { border-bottom:none; }
.resource-icon { font-size:1.4rem; width:28px; text-align:center; }
.resource-info { flex:1; min-width:0; }
.resource-name { font-size:.7rem; color:var(--text3); font-weight:500; }
.resource-val { font-size:.95rem; font-weight:700; color:var(--text); }
.goals .goal { padding:.4rem 0; border-bottom:1px solid rgba(0,0,0,.04); }
.goals .goal:last-child { border:0; }
.goal-row { display:flex; align-items:center; gap:.4rem; font-size:.78rem; }
.goal-check { width:14px; height:14px; border:1.5px solid var(--text3); border-radius:3px; display:flex; align-items:center; justify-content:center; font-size:.6rem; flex-shrink:0; }
.goal-check.done { background:var(--moss); border-color:var(--moss); color:#fff; }
.goal-bar { height:3px; background:#e8e5dc; border-radius:2px; margin-top:.3rem; overflow:hidden; }
.goal-bar-fill { height:100%; background:var(--moss); transition:width .3s; }
.center { display:flex; flex-direction:column; gap:.6rem; }
.canvas-wrap { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.05); overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,.05); position:relative; min-height:440px; }
.tutorial-overlay { position:absolute; inset:0; background:rgba(15,30,40,.85); backdrop-filter:blur(8px); display:flex; align-items:center; justify-content:center; z-index:10; padding:1.5rem; }
.tutorial-card { background:var(--white); border-radius:16px; padding:1.5rem 1.8rem; max-width:500px; box-shadow:0 20px 60px rgba(0,0,0,.3); }
.tutorial-card h3 { font-size:1.2rem; font-weight:800; margin-bottom:.6rem; color:var(--fjord); }
.tutorial-card p { font-size:.88rem; color:var(--text2); white-space:pre-line; margin-bottom:1rem; }
.tutorial-card .progress-dots { display:flex; gap:.3rem; justify-content:center; margin:.8rem 0; }
.dot { width:8px; height:8px; border-radius:50%; background:#dadad0; }
.dot.active { background:var(--fjord); }
.tut-btn { width:100%; padding:.7rem; background:var(--fjord); color:#fff; border:none; border-radius:10px; font-weight:700; font-size:.9rem; cursor:pointer; }
.tut-btn:hover { background:var(--fjord-d); }
.speed-bar { display:flex; gap:.3rem; padding:.5rem; background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); }
.speed-btn { flex:1; padding:.5rem; border:none; background:transparent; color:var(--text2); font-size:.8rem; font-weight:600; cursor:pointer; border-radius:6px; }
.speed-btn:hover { background:#f0eeea; }
.speed-btn.active { background:var(--fjord); color:#fff; }
.graphs { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
.graph-card { background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); padding:.6rem; }
.graph-title { font-size:.65rem; color:var(--text3); font-weight:600; text-transform:uppercase; letter-spacing:.06em; margin-bottom:.2rem; }
.graph-svg { width:100%; height:50px; }
.right { display:flex; flex-direction:column; gap:.8rem; }
.measure-card { display:flex; gap:.6rem; padding:.6rem; border-radius:10px; border:1px solid rgba(0,0,0,.06); margin-bottom:.4rem; cursor:pointer; transition:all .2s; background:var(--white); }
.measure-card:hover { border-color:var(--fjord); background:var(--fjord-l); }
.measure-card.disabled { opacity:.5; cursor:not-allowed; }
.measure-card.disabled:hover { border-color:rgba(0,0,0,.06); background:var(--white); }
.measure-icon { font-size:1.5rem; width:32px; text-align:center; }
.measure-info { flex:1; min-width:0; }
.measure-name { font-size:.8rem; font-weight:700; }
.measure-desc { font-size:.66rem; color:var(--text3); line-height:1.3; }
.measure-stats { display:flex; gap:.3rem; flex-wrap:wrap; margin-top:.2rem; }
.measure-stat { font-size:.6rem; padding:1px 5px; border-radius:4px; background:#f0eeea; color:var(--text3); font-weight:600; }
.measure-cost { background:var(--sand-l); color:var(--sand); }
.measure-reduction { background:var(--moss-l); color:var(--moss); }
.measure-protection { background:var(--fjord-l); color:var(--fjord); }
.measure-capacity { background:#e8e3f2; color:#7a5ea8; }
.measure-quality { background:var(--coral-l); color:var(--danger); }
.measure-neutral { background:#f0eeea; color:var(--text3); }
.events-list { max-height:180px; overflow-y:auto; }
.event { padding:.4rem .5rem; border-radius:8px; margin-bottom:.3rem; font-size:.74rem; line-height:1.3; }
.event-info { background:#f0eeea; color:var(--text2); }
.event-success { background:var(--moss-l); color:var(--moss); }
.event-warning { background:var(--sand-l); color:var(--sand); }
.event-danger { background:var(--coral-l); color:var(--danger); }
.end-screen { position:fixed; inset:0; background:rgba(15,30,40,.9); backdrop-filter:blur(10px); display:flex; align-items:center; justify-content:center; z-index:100; padding:2rem; }
.end-card { background:var(--white); border-radius:20px; padding:2rem 2.5rem; max-width:500px; text-align:center; box-shadow:0 30px 80px rgba(0,0,0,.4); }
.end-icon { font-size:4rem; margin-bottom:.5rem; }
.end-card h2 { font-size:1.8rem; font-weight:900; letter-spacing:-.02em; margin-bottom:.3rem; }
.end-card .end-summary { color:var(--text2); margin-bottom:1rem; }
.end-stats { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; margin:1rem 0; }
.end-stat { background:#f7f6f3; border-radius:10px; padding:.8rem; }
.end-stat-val { font-size:1.4rem; font-weight:800; color:var(--fjord); }
.end-stat-lbl { font-size:.7rem; color:var(--text3); text-transform:uppercase; }
@media (max-width: 1100px) {
.game-layout { grid-template-columns:1fr; }
.left, .right { order:2; }
}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim · Energiewende</a>
<div class="nav-actions">
<button class="nav-btn" id="btn-save">💾 Speichern</button>
<button class="nav-btn" id="btn-load">📂 Laden</button>
<button class="nav-btn" id="btn-finish">⏹ Beenden</button>
<button class="nav-btn" id="btn-reset" title="Stand löschen und neu starten">🔄 Reset</button>
<a href="index.html" class="nav-btn">← Zurück</a>
</div>
</nav>
<div class="game-layout">
<aside class="left">
<div class="panel">
<div class="panel-h">📊 Status</div>
<div id="resources"></div>
</div>
<div class="panel goals">
<div class="panel-h">🎯 Ziele</div>
<div id="goals"></div>
</div>
</aside>
<main class="center">
<div class="canvas-wrap" id="canvas-wrap">
<div class="tutorial-overlay" id="tutorial">
<div class="tutorial-card">
<h3 id="tut-title"></h3>
<p id="tut-text"></p>
<div class="progress-dots" id="tut-dots"></div>
<button class="tut-btn" id="tut-next">Weiter →</button>
</div>
</div>
</div>
<div class="speed-bar">
<button class="speed-btn" data-speed="0"></button>
<button class="speed-btn active" data-speed="1">▶ Normal</button>
<button class="speed-btn" data-speed="2">⏩ Schnell</button>
<button class="speed-btn" data-speed="4">⏭ Sehr schnell</button>
</div>
<div class="graphs">
<div class="graph-card">
<div class="graph-title">🌱 Erneuerbar-Anteil</div>
<svg class="graph-svg" id="graph-renewable"></svg>
</div>
<div class="graph-card">
<div class="graph-title">🌫 CO₂-Ausstoß</div>
<svg class="graph-svg" id="graph-co2"></svg>
</div>
<div class="graph-card">
<div class="graph-title">⚡ Erzeugung vs. Bedarf</div>
<svg class="graph-svg" id="graph-supply"></svg>
</div>
<div class="graph-card">
<div class="graph-title">💰 Budget</div>
<svg class="graph-svg" id="graph-budget"></svg>
</div>
</div>
</main>
<aside class="right">
<div class="panel">
<div class="panel-h">⚡ Kraftwerke bauen</div>
<div id="measures"></div>
</div>
<div class="panel">
<div class="panel-h">📰 Ereignisse</div>
<div class="events-list" id="events"></div>
</div>
</aside>
</div>
<div id="end-screen-host"></div>
<script type="module">
import { EnergiemixGame, PLANT_TYPES } from './src/sims/sim-08-energiemix/game.ts'
import { EnergiemixRenderer } from './src/sims/sim-08-energiemix/game-renderer.ts'
import { GameUI } from './src/ui/game-ui.ts'
const game = new EnergiemixGame()
const shopItems = PLANT_TYPES.map(p => {
const badges = []
if (p.capacityMW > 0) {
badges.push({ label: `${p.capacityMW} MW`, type: 'capacity' })
badges.push({ label: `CF ${(p.capacityFactor * 100).toFixed(0)}%`, type: 'neutral' })
}
if (p.renewable) badges.push({ label: '🌱 erneuerbar', type: 'reduction' })
if (p.co2PerKWh > 300) badges.push({ label: `${p.co2PerKWh} g CO₂/kWh`, type: 'quality' })
else if (p.co2PerKWh > 0 && p.capacityMW > 0) badges.push({ label: `${p.co2PerKWh} g CO₂/kWh`, type: 'neutral' })
if (p.flexible && p.capacityMW > 0) badges.push({ label: '🔄 flexibel', type: 'protection' })
if (p.storage) badges.push({ label: `🔋 ${p.storage} MW Speicher`, type: 'protection' })
return {
id: p.id,
name: p.name,
emoji: p.emoji,
description: p.description,
cost: p.cost,
upkeep: p.upkeep,
badges,
}
})
new GameUI({
game,
Renderer: EnergiemixRenderer,
shopItems,
onBuy: (id) => game.buyPlant(id),
graphs: [
{ id: 'graph-renewable', title: 'Erneuerbar', field: 'renewable', yMin: 0, yMax: 100, color: '#5a8a5e', format: v => `${v.toFixed(0)}%` },
{ id: 'graph-co2', title: 'CO₂', field: 'co2', yMin: 0, yMax: 5000, color: '#8a7868' },
{ id: 'graph-supply', title: 'Erzeugung', field: 'supply', yMin: 0, yMax: 3000, color: '#4a7c8a' },
{ id: 'graph-budget', title: 'Budget', field: 'budget', yMin: -300, yMax: 2000, color: '#c4a35a' },
],
saveKey: 'energiemix-save',
yearOffset: 2025,
})
</script>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Stadtplaner*in in Erdbebenregion</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --bg2: #efeee9; --white: #fff;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
--danger: #b04a3a;
}
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; }
.nav { padding:.6rem 1.2rem; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid rgba(0,0,0,.05); background:var(--white); }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:1rem; color:var(--fjord); text-decoration:none; }
.nav-brand img { width:28px; height:28px; border-radius:6px; }
.nav-actions { display:flex; gap:.4rem; }
.nav-btn { padding:.4rem .8rem; border:1px solid rgba(0,0,0,.08); border-radius:8px; background:var(--white); color:var(--text2); font-size:.78rem; font-weight:600; cursor:pointer; text-decoration:none; }
.nav-btn:hover { background:var(--fjord-l); border-color:var(--fjord); color:var(--fjord); }
.game-layout { display:grid; grid-template-columns:280px 1fr 280px; gap:.8rem; padding:.8rem; max-width:1400px; margin:0 auto; align-items:start; }
.panel { background:var(--white); border-radius:12px; border:1px solid rgba(0,0,0,.05); padding:.9rem; box-shadow:0 2px 8px rgba(0,0,0,.03); }
.panel-h { font-size:.66rem; font-weight:700; color:var(--fjord); text-transform:uppercase; letter-spacing:.06em; margin-bottom:.5rem; }
.left { display:flex; flex-direction:column; gap:.8rem; }
.resource { display:flex; align-items:center; gap:.6rem; padding:.4rem .2rem; border-bottom:1px solid rgba(0,0,0,.04); }
.resource:last-child { border-bottom:none; }
.resource-icon { font-size:1.4rem; width:28px; text-align:center; }
.resource-info { flex:1; min-width:0; }
.resource-name { font-size:.7rem; color:var(--text3); font-weight:500; }
.resource-val { font-size:.95rem; font-weight:700; color:var(--text); }
.goals .goal { padding:.4rem 0; border-bottom:1px solid rgba(0,0,0,.04); }
.goals .goal:last-child { border:0; }
.goal-row { display:flex; align-items:center; gap:.4rem; font-size:.78rem; }
.goal-check { width:14px; height:14px; border:1.5px solid var(--text3); border-radius:3px; display:flex; align-items:center; justify-content:center; font-size:.6rem; flex-shrink:0; }
.goal-check.done { background:var(--moss); border-color:var(--moss); color:#fff; }
.goal-bar { height:3px; background:#e8e5dc; border-radius:2px; margin-top:.3rem; overflow:hidden; }
.goal-bar-fill { height:100%; background:var(--moss); transition:width .3s; }
.center { display:flex; flex-direction:column; gap:.6rem; }
.canvas-wrap { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.05); overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,.05); position:relative; min-height:440px; }
.tutorial-overlay { position:absolute; inset:0; background:rgba(15,30,40,.85); backdrop-filter:blur(8px); display:flex; align-items:center; justify-content:center; z-index:10; padding:1.5rem; }
.tutorial-card { background:var(--white); border-radius:16px; padding:1.5rem 1.8rem; max-width:480px; box-shadow:0 20px 60px rgba(0,0,0,.3); }
.tutorial-card h3 { font-size:1.2rem; font-weight:800; margin-bottom:.6rem; color:var(--fjord); }
.tutorial-card p { font-size:.88rem; color:var(--text2); white-space:pre-line; margin-bottom:1rem; }
.tutorial-card .progress-dots { display:flex; gap:.3rem; justify-content:center; margin:.8rem 0; }
.dot { width:8px; height:8px; border-radius:50%; background:#dadad0; }
.dot.active { background:var(--fjord); }
.tut-btn { width:100%; padding:.7rem; background:var(--fjord); color:#fff; border:none; border-radius:10px; font-weight:700; font-size:.9rem; cursor:pointer; }
.tut-btn:hover { background:var(--fjord-d); }
.speed-bar { display:flex; gap:.3rem; padding:.5rem; background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); }
.speed-btn { flex:1; padding:.5rem; border:none; background:transparent; color:var(--text2); font-size:.8rem; font-weight:600; cursor:pointer; border-radius:6px; }
.speed-btn:hover { background:#f0eeea; }
.speed-btn.active { background:var(--fjord); color:#fff; }
.graphs { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
.graph-card { background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); padding:.6rem; }
.graph-title { font-size:.65rem; color:var(--text3); font-weight:600; text-transform:uppercase; letter-spacing:.06em; margin-bottom:.2rem; }
.graph-svg { width:100%; height:50px; }
.right { display:flex; flex-direction:column; gap:.8rem; }
.measure-card { display:flex; gap:.6rem; padding:.6rem; border-radius:10px; border:1px solid rgba(0,0,0,.06); margin-bottom:.4rem; cursor:pointer; transition:all .2s; background:var(--white); }
.measure-card:hover { border-color:var(--fjord); background:var(--fjord-l); }
.measure-card.disabled { opacity:.5; cursor:not-allowed; }
.measure-card.disabled:hover { border-color:rgba(0,0,0,.06); background:var(--white); }
.measure-icon { font-size:1.5rem; width:32px; text-align:center; }
.measure-info { flex:1; min-width:0; }
.measure-name { font-size:.8rem; font-weight:700; }
.measure-desc { font-size:.66rem; color:var(--text3); line-height:1.3; }
.measure-stats { display:flex; gap:.3rem; flex-wrap:wrap; margin-top:.2rem; }
.measure-stat { font-size:.6rem; padding:1px 5px; border-radius:4px; background:#f0eeea; color:var(--text3); font-weight:600; }
.measure-cost { background:var(--sand-l); color:var(--sand); }
.measure-reduction { background:var(--moss-l); color:var(--moss); }
.measure-protection { background:var(--fjord-l); color:var(--fjord); }
.measure-capacity { background:#e8e3f2; color:#7a5ea8; }
.measure-quality { background:var(--coral-l); color:var(--danger); }
.measure-neutral { background:#f0eeea; color:var(--text3); }
.events-list { max-height:180px; overflow-y:auto; }
.event { padding:.4rem .5rem; border-radius:8px; margin-bottom:.3rem; font-size:.74rem; line-height:1.3; }
.event-info { background:#f0eeea; color:var(--text2); }
.event-success { background:var(--moss-l); color:var(--moss); }
.event-warning { background:var(--sand-l); color:var(--sand); }
.event-danger { background:var(--coral-l); color:var(--danger); }
.end-screen { position:fixed; inset:0; background:rgba(15,30,40,.9); backdrop-filter:blur(10px); display:flex; align-items:center; justify-content:center; z-index:100; padding:2rem; }
.end-card { background:var(--white); border-radius:20px; padding:2rem 2.5rem; max-width:500px; text-align:center; box-shadow:0 30px 80px rgba(0,0,0,.4); }
.end-icon { font-size:4rem; margin-bottom:.5rem; }
.end-card h2 { font-size:1.8rem; font-weight:900; letter-spacing:-.02em; margin-bottom:.3rem; }
.end-card .end-summary { color:var(--text2); margin-bottom:1rem; }
.end-stats { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; margin:1rem 0; }
.end-stat { background:#f7f6f3; border-radius:10px; padding:.8rem; }
.end-stat-val { font-size:1.4rem; font-weight:800; color:var(--fjord); }
.end-stat-lbl { font-size:.7rem; color:var(--text3); text-transform:uppercase; }
@media (max-width: 1100px) {
.game-layout { grid-template-columns:1fr; }
.left, .right { order:2; }
}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim · Erdbebenregion</a>
<div class="nav-actions">
<button class="nav-btn" id="btn-save">💾 Speichern</button>
<button class="nav-btn" id="btn-load">📂 Laden</button>
<button class="nav-btn" id="btn-finish">⏹ Beenden</button>
<button class="nav-btn" id="btn-reset" title="Stand löschen und neu starten">🔄 Reset</button>
<a href="index.html" class="nav-btn">← Zurück</a>
</div>
</nav>
<div class="game-layout">
<aside class="left">
<div class="panel">
<div class="panel-h">📊 Status</div>
<div id="resources"></div>
</div>
<div class="panel goals">
<div class="panel-h">🎯 Ziele</div>
<div id="goals"></div>
</div>
</aside>
<main class="center">
<div class="canvas-wrap" id="canvas-wrap">
<div class="tutorial-overlay" id="tutorial">
<div class="tutorial-card">
<h3 id="tut-title"></h3>
<p id="tut-text"></p>
<div class="progress-dots" id="tut-dots"></div>
<button class="tut-btn" id="tut-next">Weiter →</button>
</div>
</div>
</div>
<div class="speed-bar">
<button class="speed-btn" data-speed="0"></button>
<button class="speed-btn active" data-speed="1">▶ Normal</button>
<button class="speed-btn" data-speed="2">⏩ Schnell</button>
<button class="speed-btn" data-speed="4">⏭ Sehr schnell</button>
</div>
<div class="graphs">
<div class="graph-card">
<div class="graph-title">👥 Bevölkerung</div>
<svg class="graph-svg" id="graph-pop"></svg>
</div>
<div class="graph-card">
<div class="graph-title">🏠 Wohnraum</div>
<svg class="graph-svg" id="graph-housed"></svg>
</div>
<div class="graph-card">
<div class="graph-title">💰 Budget</div>
<svg class="graph-svg" id="graph-budget"></svg>
</div>
<div class="graph-card">
<div class="graph-title">🕯️ Opfer (kumulativ)</div>
<svg class="graph-svg" id="graph-deaths"></svg>
</div>
</div>
</main>
<aside class="right">
<div class="panel">
<div class="panel-h">🏗 Häuser bauen</div>
<div id="measures"></div>
</div>
<div class="panel">
<div class="panel-h">📰 Ereignisse</div>
<div class="events-list" id="events"></div>
</div>
</aside>
</div>
<div id="end-screen-host"></div>
<script type="module">
import { ErdbebenGame, BUILDING_TYPES } from './src/sims/sim-07-erdbeben/game.ts'
import { ErdbebenRenderer } from './src/sims/sim-07-erdbeben/game-renderer.ts'
import { GameUI } from './src/ui/game-ui.ts'
const game = new ErdbebenGame()
const shopItems = BUILDING_TYPES.map(b => ({
id: b.id,
name: b.name,
emoji: b.emoji,
description: b.description,
cost: b.cost,
upkeep: b.upkeep,
badges: [
b.capacity > 0 ? { label: `👥 ${b.capacity} Plätze`, type: 'capacity' } : null,
{ label: `🛡 Q ${(b.quality * 100).toFixed(0)}%`, type: b.quality >= 0.6 ? 'protection' : 'quality' },
].filter(Boolean),
}))
new GameUI({
game,
Renderer: ErdbebenRenderer,
shopItems,
onBuy: (id) => game.buyBuilding(id),
graphs: [
{ id: 'graph-pop', title: 'Bevölkerung', field: 'population', yMin: 0, yMax: 6000, color: '#4a7c8a' },
{ id: 'graph-housed', title: 'Wohnraum', field: 'housed', yMin: 0, yMax: 6000, color: '#5a8a5e' },
{ id: 'graph-budget', title: 'Budget', field: 'budget', yMin: -300, yMax: 1500, color: '#c4a35a' },
{ id: 'graph-deaths', title: 'Opfer', field: 'deaths', yMin: 0, yMax: 500, color: '#b04a3a' },
],
saveKey: 'erdbeben-save',
yearOffset: 1975,
})
</script>
</body>
</html>
+859
View File
@@ -0,0 +1,859 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Flussmanagement</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#f7f6f3;--white:#fff;--text:#1a1a1a;--text2:#4a4a4a;--text3:#8a8a8a;--fjord:#4a7c8a;--fjord-l:#dae8ec;--fjord-d:#3a6470;--moss:#5a8a5e;--moss-l:#dceadd;--sand:#c4a35a;--coral:#c07a6b}
html,body{height:100%;overflow:hidden}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column}
.nav{padding:.4rem .8rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(0,0,0,.05);background:var(--white);flex-shrink:0}
.nav-brand{display:flex;align-items:center;gap:.4rem;font-weight:800;font-size:.9rem;color:var(--fjord);text-decoration:none}
.nav-brand img{width:24px;height:24px;border-radius:5px}
.nav-btn{padding:.25rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:var(--white);color:var(--text2);font-size:.68rem;font-weight:600;cursor:pointer;text-decoration:none}
.nav-btn:hover{background:var(--fjord-l);color:var(--fjord)}
.main{flex:1;display:grid;grid-template-columns:190px 1fr 200px;gap:.4rem;padding:.4rem;min-height:0;overflow:hidden}
.panel{background:var(--white);border-radius:9px;border:1px solid rgba(0,0,0,.04);padding:.5rem;box-shadow:0 1px 4px rgba(0,0,0,.02);overflow-y:auto}
.panel-h{font-size:.58rem;font-weight:700;color:var(--fjord);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.3rem}
.col-left,.col-right{display:flex;flex-direction:column;gap:.4rem;overflow-y:auto}
.col-center{display:flex;flex-direction:column;gap:.3rem;min-height:0}
.topbar{flex-shrink:0;display:flex;align-items:center;gap:.4rem;background:var(--white);border-radius:8px;padding:.3rem .6rem;border:1px solid rgba(0,0,0,.04);flex-wrap:wrap}
.topbar .stat{font-size:.68rem;font-weight:700;display:flex;align-items:center;gap:.2rem}
.topbar .stat .v{font-size:.85rem}
.topbar .gold .v{color:var(--sand)}
.topbar .yr .v{color:var(--fjord)}
.topbar .inc{font-size:.58rem;color:var(--moss);font-weight:600}
.topbar .wx{font-size:.78rem;padding:.1rem .35rem;border-radius:4px;background:var(--bg);font-weight:600}
.spd{display:flex;gap:.1rem;margin-left:auto}
.spd button{width:26px;height:22px;border:1px solid rgba(0,0,0,.07);border-radius:4px;background:var(--white);font-size:.62rem;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center}
.spd button.on{background:var(--fjord);color:#fff;border-color:var(--fjord)}
.canvas-wrap{flex:1;background:#dde6d4;border-radius:9px;overflow:hidden;position:relative;min-height:200px;cursor:crosshair}
.canvas-wrap canvas{width:100%;height:100%;display:block}
.param{display:flex;align-items:center;gap:.3rem;padding:.18rem 0;border-bottom:1px solid rgba(0,0,0,.02)}.param:last-child{border:none}
.param-i{font-size:.75rem;width:17px;text-align:center}.param-inf{flex:1}
.param-n{font-size:.55rem;color:var(--text3)}.param-bar{height:3.5px;background:#e8e5dc;border-radius:2px;margin-top:1px;overflow:hidden}
.param-bar-f{height:100%;border-radius:2px;transition:width .8s}.param-v{font-size:.62rem;font-weight:700;min-width:17px;text-align:right}
.tool{display:flex;align-items:center;gap:.3rem;padding:.3rem .4rem;border:1.5px solid rgba(0,0,0,.05);border-radius:6px;background:var(--white);cursor:pointer;width:100%;text-align:left;margin-bottom:.18rem;transition:all .12s}
.tool:hover{border-color:var(--fjord);background:var(--fjord-l)}.tool.on{border-color:var(--fjord);background:var(--fjord);color:#fff}
.tool.off{opacity:.3;pointer-events:none;filter:grayscale(100%);background:var(--bg)!important;border-color:rgba(0,0,0,.05)!important;color:var(--text3)!important}
.tool-e{font-size:.85rem}.tool-inf{flex:1}.tool-nm{font-size:.62rem;font-weight:600}.tool-ds{font-size:.5rem;color:var(--text3)}.tool-c{font-size:.52rem;font-weight:600;color:var(--sand)}
.log{font-size:.55rem;padding:.12rem 0;border-bottom:1px solid rgba(0,0,0,.02);color:var(--text2)}.log.bad{color:var(--coral)}.log.good{color:var(--moss)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(3px)}
.ov-c{background:var(--white);border-radius:14px;padding:1.4rem;max-width:400px;width:90%;text-align:center}
.ov-c h2{font-size:1rem;font-weight:800;margin-bottom:.2rem}.ov-c p{font-size:.75rem;color:var(--text2);margin-bottom:.5rem;line-height:1.4}
.ov-btn{padding:.4rem 1rem;border:none;border-radius:6px;background:var(--fjord);color:#fff;font-size:.75rem;font-weight:700;cursor:pointer}
.hidden{display:none!important}
@media(max-width:800px){.main{grid-template-columns:1fr}}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">Flussmanagement</a>
<div style="display:flex;gap:.2rem"><button id="btn-reset" class="nav-btn">🔄 Neu</button><a href="index.html" class="nav-btn">← Zurück</a></div>
</nav>
<div id="game" class="main">
<div class="col-left">
<div class="panel"><div class="panel-h">Zustand</div><div id="params"></div></div>
<div class="panel"><div class="panel-h">Verlauf</div><canvas id="graph" style="width:100%;height:90px;display:block;border-radius:6px;background:#f4f2ee"></canvas></div>
<div class="panel"><div class="panel-h">Ereignisse</div><div id="log" style="max-height:100px;overflow-y:auto"></div></div>
</div>
<div class="col-center">
<div class="topbar">
<div class="stat gold">💰 <span class="v" id="h-bud">30</span></div>
<span class="inc" id="h-inc">+0/J</span>
<span class="wx" id="h-wx"></span>
<div class="stat yr">📅 <span class="v" id="h-yr">0</span></div>
<div class="spd"><button data-s="0"></button><button data-s="1" class="on"></button><button data-s="2">▶▶</button><button data-s="4">▶▶▶</button></div>
</div>
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
</div>
<div class="col-right">
<div class="panel"><div class="panel-h">Werkzeug</div><div id="tools"></div></div>
<div class="panel"><div class="panel-h">Info</div><div id="info" style="font-size:.6rem;color:var(--text2)">Maus über die Karte bewegen.</div></div>
</div>
</div>
<div id="ov-intro" class="ov">
<div class="ov-c">
<div style="font-size:1.8rem;margin-bottom:.15rem">🏞️</div>
<h2>Flussmanagement</h2>
<p>Der Fluss lebt — er ändert jedes Jahr seinen Lauf! Baue Siedlungen und Felder, aber schütze sie mit Deichen. Begradige den Fluss wenn nötig, aber die Natur rächt sich flussabwärts.</p>
<button class="ov-btn" id="btn-go">Los geht's!</button>
</div>
</div>
<div id="ov-end" class="ov hidden">
<div class="ov-c"><h2 id="end-t"></h2><p id="end-p"></p><button class="ov-btn" id="btn-again">Nochmal</button></div>
</div>
<script>
// =====================================================================
// FLUSSMANAGEMENT v3 — Punkt-basierter Fluss mit Kreisen
// Fluss = 16 Punkte (Spaltenposition), verbunden durch Kreise
// Pro Jahr wandert EIN Punkt 1 Feld links/rechts
// =====================================================================
const COLS = 20, ROWS = 16
const MAX_YEARS = 100, MS_PER_TICK = 2500, WX_INTERVAL = 3
// Fluss: Array von 16 Spaltenpositionen (0..COLS-1)
let riverCol = [] // Ganzzahlige Zielpositionen (Spalte)
let riverColSmooth = [] // Smooth-interpolierte Anzeigepositionen (fliessend)
let riverEng = [] // Befestigung pro Segment (0=natur, 1=holz, 2=stein, 3=beton)
let riverRadius = [] // Radius pro Segment (variiert)
// Land-Grid
let land = [] // ROWS x COLS
let tilePop = [], tileCap = [], tileYield = []
let floodIntensity = [] // 0..1 smooth per tile
let tileFloodYears = [] // wie viele Jahre am Stueck ueberschwemmt
// Game
let budget = 30, year = 0, speed = 1, gameOver = false
let tickAccum = 0, wxTimer = 0, selectedTool = null
let eventLog = [], floats = [] // floating emojis
let history = [] // [{year, pop, food, bio, safety, economy}]
const WX = [
{n:'Dürre',e:'☀️',f:0.2,p:.12},{n:'Trocken',e:'🌤️',f:0.4,p:.25},
{n:'Normal',e:'⛅',f:0.8,p:.33},{n:'Regen',e:'🌧️',f:1.5,p:.20},{n:'Starkregen',e:'⛈️',f:2.8,p:.10}
]
let weather = WX[2]
function rollWx(){let r=Math.random(),c=0;for(const w of WX){c+=w.p;if(r<c)return w}return WX[2]}
const TOOLS = [
{id:'inspect',nm:'Experte fragen',e:'🧑‍🏫',ds:'Klicke irgendwo — der Experte erklärt die Lage.',cost:0},
{id:'levee',nm:'Deich',e:'🧱',ds:'Hält Fluss auf. Kann bei Dauerflut brechen.',cost:6},
{id:'field',nm:'Feld',e:'🌾',ds:'Ertrag je nach Feuchtigkeit. Stirbt nach 2J Flut.',cost:4},
{id:'settlement',nm:'Siedlung',e:'🏘️',ds:'Bewohner zahlen Steuern. Fliehen bei Flut.',cost:10},
{id:'forest',nm:'Wald',e:'🌳',ds:'Bremst Erosion, schützt Grundwasser. Trocknet neben Begradigung aus.',cost:5},
{id:'demolish',nm:'Abreißen',e:'🗑️',ds:'Entfernt alles — auch Begradigung!',cost:2},
{id:'straighten',nm:'Begradigen',e:'📏',ds:'Zieht Fluss zur Mitte. Fixiert ihn dort. Trocknet Umgebung aus!',cost:60},
]
function initGame() {
// Fluss: startet als Maeander
riverCol = [8,9,10,11,10,9,8,7,8,9,10,11,10,9,8,7]
riverColSmooth = riverCol.map(c => c) // Kopie fuer Animation
riverEng = new Array(ROWS).fill(0)
// Variierende Radien — Fluss ist nicht ueberall gleich breit
riverRadius = riverCol.map((_, i) => 0.8 + Math.sin(i * 0.9) * 0.3 + Math.random() * 0.2)
land = []; floodIntensity = []; tilePop = []; tileCap = []; tileYield = []; tileFloodYears = []
for (let r = 0; r < ROWS; r++) {
land[r] = []; floodIntensity[r] = []; tilePop[r] = []; tileCap[r] = []; tileYield[r] = []; tileFloodYears[r] = []
for (let c = 0; c < COLS; c++) {
floodIntensity[r][c] = 0; tilePop[r][c] = 0; tileCap[r][c] = 0; tileYield[r][c] = 0; tileFloodYears[r][c] = 0
// Wald weit vom Fluss, Wiese nah dran
const dist = Math.abs(c - riverCol[r])
if (dist <= 1) land[r][c] = 'meadow'
else if (dist > 4 && Math.random() < 0.5) land[r][c] = 'forest'
else land[r][c] = 'meadow'
}
}
// Startgebaeude
land[4][13] = 'settlement'; land[5][13] = 'field'; land[5][14] = 'field'
tilePop[4][13] = 3; tileCap[4][13] = 8
budget = 30; year = 0; speed = 1; gameOver = false
weather = WX[2]; wxTimer = 0; tickAccum = 0
eventLog = [{t:'🏞️ Willkommen! Der Fluss wandert — plane voraus!',c:'good'}]; history = []
selectedTool = null; floats = []
}
// === FLUSS-GEOMETRIE ===
// Abstand eines Tiles (r,c) zum Fluss (kontinuierlich, nicht nur ganzzahlig)
function distToRiver(r, c) {
// Naechster Punkt auf der Flusslinie
let minD = 999
for (let i = 0; i < ROWS; i++) {
const rc = riverColSmooth[i]
// Segment von Zeile i zu i+1
if (i < ROWS - 1) {
const rc2 = riverColSmooth[i + 1]
// Punkt-zu-Segment Abstand (vereinfacht: nehme naechsten Endpunkt)
const d1 = Math.sqrt((r - i) ** 2 + (c - rc) ** 2)
const d2 = Math.sqrt((r - (i + 1)) ** 2 + (c - rc2) ** 2)
minD = Math.min(minD, d1, d2)
} else {
minD = Math.min(minD, Math.sqrt((r - i) ** 2 + (c - rc) ** 2))
}
}
return minD
}
function isRiverTile(r, c) {
return distToRiver(r, c) < 1.3
}
// === TICK ===
function tick() {
if (gameOver) return
year++
// 1. Einkommen
let income = 0, totalPop = 0, totalFood = 0
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const dist = distToRiver(r, c)
const moisture = Math.max(0, 1 - Math.abs(dist - 2.5) / 3) // optimal bei ~2.5 Felder Abstand
const fi = floodIntensity[r][c]
if (land[r][c] === 'field') {
// Begradigung in der Naehe trocknet Feld aus
let dryPenalty = 0
for (let i = Math.max(0, r-1); i <= Math.min(ROWS-1, r+1); i++) {
if (riverEng[i] > 0) dryPenalty = Math.max(dryPenalty, riverEng[i] * 0.25)
}
const effMoisture = Math.max(0, moisture - dryPenalty)
// Ertrag: Basis-Feuchtigkeit + braucht Arbeiter in der Naehe
let workerBonus = 0
for (let dr=-2;dr<=2;dr++) for(let dc=-2;dc<=2;dc++){
const nr=r+dr,nc=c+dc
if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS&&land[nr][nc]==='settlement')
workerBonus += tilePop[nr][nc] * 0.15
}
workerBonus = Math.min(1, workerBonus) // max 100% Arbeiterbonus
const baseYield = (1 + effMoisture * 5) * (1 - fi * 0.9)
const y = Math.round(Math.max(0, Math.min(8, baseYield * (0.3 + workerBonus * 0.7))))
tileYield[r][c] = y; income += y * 0.5; totalFood += y
}
if (land[r][c] === 'settlement') {
tileCap[r][c] = 8
const cur = tilePop[r][c]
// Zuzug: braucht Nahrung UND trockenes Haus
const hasFood = totalFood > totalPop * 0.4
if (cur < 8 && hasFood && fi < 0.15) {
tilePop[r][c] = Math.min(8, cur + (Math.random() < 0.35 ? 1 : 0))
}
// Feuchtigkeit → Bewohner werden krank, fliehen langsam
if (fi > 0.2 && cur > 0 && Math.random() < fi * 0.2) {
tilePop[r][c] = Math.max(0, cur - 1)
}
totalPop += tilePop[r][c]
income += tilePop[r][c] * 0.8
}
}
budget += Math.round(income)
// 2. Wetter
wxTimer++; if (wxTimer >= WX_INTERVAL) { wxTimer = 0; weather = rollWx() }
// 3. FLUSS WANDERT — EIN Punkt pro Jahr
const idx = Math.floor(Math.random() * ROWS)
if (riverEng[idx] === 0) { // nur wenn nicht befestigt
const dir = Math.random() < 0.5 ? -1 : 1
const newCol = riverCol[idx] + dir
// Max 3 Felder Abstand zum Nachbarn
const prevOk = idx === 0 || Math.abs(newCol - riverCol[idx - 1]) <= 3
const nextOk = idx === ROWS - 1 || Math.abs(newCol - riverCol[idx + 1]) <= 3
if (newCol >= 1 && newCol < COLS - 1 && prevOk && nextOk) {
riverCol[idx] = newCol
// Was auf dem neuen Feld stand → zerstoeren
if (land[idx][newCol] === 'settlement') {
land[idx][newCol] = 'ruin'
eventLog.push({t: `🌊 Fluss verschlingt Siedlung! (Zeile ${idx+1})`, c: 'bad'})
} else if (land[idx][newCol] === 'field') {
land[idx][newCol] = 'meadow'
eventLog.push({t: `🌊 Fluss überspült Feld (Zeile ${idx+1})`, c: 'bad'})
} else if (land[idx][newCol] === 'levee') {
// Deich haelt — Fluss wird zurueckgedraengt
riverCol[idx] -= dir
eventLog.push({t: `🧱 Deich hält! (Zeile ${idx+1})`, c: 'good'})
}
}
}
// 4. Hochwasser + Trockenheit durch Begradigung
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const dist = distToRiver(r, c)
let target = 0
if (weather.f >= 1.0 && dist < 4) {
const base = Math.max(0, 1 - dist / 4)
const leveeProt = land[r][c] === 'levee' ? 0.9 : (hasLeveeNear(r, c) ? 0.5 : 0)
target = base * weather.f * 0.4 * (1 - leveeProt)
}
floodIntensity[r][c] += (target - floodIntensity[r][c]) * 0.2
// Flood-Jahre tracken
if (floodIntensity[r][c] > 0.35) {
tileFloodYears[r][c] = (tileFloodYears[r][c] || 0) + 1
} else {
tileFloodYears[r][c] = 0
}
// Feld stirbt nach 2 Jahren Dauerflut
if (land[r][c] === 'field' && tileFloodYears[r][c] >= 2) {
land[r][c] = 'meadow'
eventLog.push({t: `🌾💀 Feld ertrunken! (${c+1},${r+1})`, c: 'bad'})
}
// Siedlung: Bewohner fliehen bei Flut, Gebaeude wird Ruine nach 3J
if (land[r][c] === 'settlement' && floodIntensity[r][c] > 0.5 && Math.random() < 0.05) {
land[r][c] = 'ruin'; eventLog.push({t:`🏚️ Siedlung zerstört!`,c:'bad'})
}
// Wald stirbt bei Dauerflut
if (land[r][c] === 'forest' && tileFloodYears[r][c] >= 3) {
land[r][c] = 'dead_tree'
}
// Deich-Erosion bei Dauerflut
if (land[r][c] === 'levee' && floodIntensity[r][c] > 0.4 && Math.random() < 0.08) {
land[r][c] = 'meadow'; eventLog.push({t:'🧱💨 Deich weggespült!',c:'bad'})
}
}
// 4b. Begradigung trocknet Umgebung aus
for (let i = 0; i < ROWS; i++) {
if (riverEng[i] > 0) {
// 2 Felder links und rechts vom begradigten Fluss werden trocken
const rc = riverCol[i]
for (let dc = -3; dc <= 3; dc++) {
const cc = rc + dc
if (cc < 0 || cc >= COLS) continue
if (Math.abs(dc) <= 1) continue // direkt am Fluss ist eh Wasser
// Baeume sterben langsam neben Begradigung
if (land[i][cc] === 'forest' && Math.random() < 0.03 * riverEng[i]) {
land[i][cc] = 'dead_tree'
eventLog.push({t:'🌳💀 Baum vertrocknet neben Begradigung',c:'bad'})
}
// Felder neben Begradigung produzieren weniger (wird in Yield-Berechnung beruecksichtigt)
}
}
}
// 5. Flussbreite bei Hochwasser
for (let i = 0; i < ROWS; i++) {
riverRadius[i] += ((weather.f > 1.2 ? 1.8 : 1.2) - riverRadius[i]) * 0.1
}
// 6. Verfall
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (land[r][c] === 'ruin' && Math.random() < 0.03) land[r][c] = 'meadow'
if (land[r][c] === 'dead_tree' && Math.random() < 0.03) land[r][c] = 'meadow'
}
// History fuer Graph speichern
const avgEng_=riverEng.reduce((s,v)=>s+v,0)/ROWS
const floodTot=floodIntensity.flat().reduce((s,v)=>s+v,0)/(ROWS*COLS)
const clp=v=>Math.min(100,Math.max(0,Math.round(v)))
let h_sett=0,h_fld=0,h_for=0,h_lev=0,h_ruin=0;for(let rr=0;rr<ROWS;rr++)for(let cc=0;cc<COLS;cc++){const tt=land[rr][cc];if(tt==='settlement')h_sett++;if(tt==='field')h_fld++;if(tt==='forest')h_for++;if(tt==='levee')h_lev++;if(tt==='ruin')h_ruin++}
history.push({
year,
pop:clp(totalPop*5-h_ruin*8),
food:clp(totalFood*1.5),
bio:clp(h_for*4-avgEng_*30-(h_sett+h_fld)*1.5),
safety:clp(15+h_lev*6+avgEng_*8-floodTot*80),
economy:clp(totalPop*3+h_fld*2-h_ruin*5)
})
if (year >= MAX_YEARS) { gameOver = true; showEnd() }
// Wetter-Emojis
if (weather.f >= 1.5) floats.push({x:Math.random(),y:-0.05,e:weather.f>=2.5?'⛈️':'🌧️',t:performance.now()})
if (weather.f <= 0.3) floats.push({x:Math.random()*0.5+0.25,y:-0.05,e:'☀️',t:performance.now()})
renderUI()
}
function hasLeveeNear(r, c) {
for (let dr=-1;dr<=1;dr++) for (let dc=-1;dc<=1;dc++) {
const nr=r+dr,nc=c+dc
if (nr>=0&&nr<ROWS&&nc>=0&&nc<COLS&&land[nr][nc]==='levee') return true
}
return false
}
// === APPLY TOOL ===
function applyAt(gr, gc) {
if (gameOver || !selectedTool || gr<0 || gr>=ROWS || gc<0 || gc>=COLS) return
const t = selectedTool, cur = land[gr][gc]
const onRiver = isRiverTile(gr, gc)
const nearRiver = distToRiver(gr, gc) < 2.5
if (t === 'inspect') {
const d = distToRiver(gr, gc)
const fi = floodIntensity[gr]?.[gc] || 0
const fy = tileFloodYears[gr]?.[gc] || 0
if (onRiver) {
const eng = riverEng[gr]
const engNames = ['natürlich','Holzverbau','Bruchstein','Beton']
let msg = `🌊 Fluss — Befestigung: ${engNames[eng] || 'natürlich'}.`
if (eng > 0) msg += ` Fluss ist hier fixiert. Umgebung trocknet aus.`
else msg += ` Fließt frei — kann jedes Jahr wandern!`
eventLog.push({t: msg, c: ''})
} else if (cur === 'field') {
const yi = tileYield[gr]?.[gc] || 0
let msg = `🌾 Feld — Ertrag: ${yi}/8.`
if (fi > 0.35) msg += ` ⚠️ Abgesoffen! Kaum Ertrag. Noch ${2-fy} Jahr(e) bis es stirbt.`
else if (fi > 0.15) msg += ` 💧 Zu feucht — Ertrag reduziert.`
else if (d > 5) msg += ` 🥵 Sehr trocken — weit vom Fluss. Wenig Ertrag.`
else if (d > 3.5) msg += ` Etwas trocken. Näherer Standort wäre besser.`
else msg += ` 👍 Gute Feuchtigkeit!`
let hasWorkers = false
for(let dr=-2;dr<=2;dr++)for(let dc=-2;dc<=2;dc++){const nr=gr+dr,nc=gc+dc;if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS&&land[nr][nc]==='settlement'&&tilePop[nr][nc]>0)hasWorkers=true}
if (!hasWorkers) msg += ` ⚠️ Keine Arbeiter! Siedlung in der Nähe nötig.`
eventLog.push({t: msg, c: yi > 4 ? 'good' : yi > 1 ? '' : 'bad'})
} else if (cur === 'settlement') {
const pop = tilePop[gr]?.[gc] || 0
let msg = `🏘️ Siedlung — ${pop}/8 Bewohner.`
if (fi > 0.3) msg += ` 🤒 Feucht! Bewohner werden krank und fliehen.`
else if (fi > 0.1) msg += ` 😰 Etwas feucht. Manche Bewohner sind unwohl.`
else msg += ` Trocken und sicher.`
if (pop < 8) msg += ` Neue Bewohner kommen, wenn genug Nahrung da ist.`
eventLog.push({t: msg, c: fi > 0.2 ? 'bad' : 'good'})
} else if (cur === 'levee') {
let msg = `🧱 Deich.`
if (fi > 0.3) msg += ` ⚠️ Unter Druck! Kann bei Dauerflut brechen.`
else msg += ` Intakt. Schützt dahinterliegende Flächen.`
eventLog.push({t: msg, c: fi > 0.3 ? 'bad' : 'good'})
} else if (cur === 'forest') {
let msg = `🌳 Wald — schützt vor Erosion, speichert Grundwasser.`
let nearStraight = false
for(let i=Math.max(0,gr-1);i<=Math.min(ROWS-1,gr+1);i++){if(riverEng[i]>0)nearStraight=true}
if (nearStraight) msg += ` ⚠️ Neben Begradigung — trocknet aus, Baum in Gefahr!`
if (fi > 0.2) msg += ` 💧 Feucht. Bei Dauerflut stirbt der Baum.`
eventLog.push({t: msg, c: nearStraight ? 'bad' : 'good'})
} else if (cur === 'ruin') {
eventLog.push({t: `🏚️ Ruine — ehemaliges Gebäude. Kann abgerissen werden (💰2). Verfällt mit der Zeit.`, c: 'bad'})
} else if (cur === 'dead_tree') {
eventLog.push({t: `🪵 Toter Baum — vertrocknet oder ertrunken. Verrottet langsam.`, c: 'bad'})
} else {
let msg = `🌱 Wiese — freie Fläche.`
if (d < 2.5) msg += ` Nah am Fluss: fruchtbar, aber hochwassergefährdet.`
else if (d < 4) msg += ` Gute Lage für Feld oder Siedlung.`
else msg += ` Weit vom Fluss. Trocken.`
eventLog.push({t: msg, c: ''})
}
renderUI(); return
}
if (t === 'straighten') {
if (!onRiver) return
if (budget < 60) return
// Begradigung: Fluss wird zur Bildmitte (Spalte 10) gezogen
riverEng[gr] = Math.min(3, riverEng[gr] + 1)
riverCol[gr] = COLS / 2 // Mitte
budget -= 60
eventLog.push({t:`📏 Fluss begradigt zur Mitte (Zeile ${gr+1}) — 💰60`,c:''})
} else if (t === 'levee') {
if (onRiver) return
const dRiv = distToRiver(gr, gc)
if (dRiv > 4) { eventLog.push({t:'🧱 Zu weit vom Fluss! Deich muss in Flussnähe stehen.',c:'bad'}); renderUI(); return }
if (cur !== 'meadow' && cur !== 'forest') return
if (budget < 6) return
land[gr][gc] = 'levee'; budget -= 6
} else if (t === 'field') {
if (onRiver) return
if (cur !== 'meadow') return
if (budget < 4) return
land[gr][gc] = 'field'; budget -= 4
// Kontextuelle Rueckmeldung
const d = distToRiver(gr, gc)
if (d < 2.5) eventLog.push({t:'🌾 Feld angelegt — schön nah am Wasser! Guter Ertrag, aber bei Hochwasser gefährdet.',c:'good'})
else if (d < 4) eventLog.push({t:'🌾 Feld angelegt — gute Lage. Genug Feuchtigkeit, sicher vor dem Fluss.',c:'good'})
else eventLog.push({t:'🌾 Feld angelegt — weit vom Fluss! Hier wird es trocken. Ertrag wird gering sein.',c:'bad'})
// Arbeiter-Hinweis
let hasWorkers = false
for(let dr=-2;dr<=2;dr++)for(let dc=-2;dc<=2;dc++){const nr=gr+dr,nc=gc+dc;if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS&&land[nr][nc]==='settlement'&&tilePop[nr][nc]>0)hasWorkers=true}
if(!hasWorkers) eventLog.push({t:'⚠️ Keine Siedlung in der Nähe! Ohne Arbeiter nur 30% Ertrag.',c:'bad'})
} else if (t === 'settlement') {
if (onRiver) return
if (cur !== 'meadow' && cur !== 'field') return
if (budget < 10) return
land[gr][gc] = 'settlement'; tilePop[gr][gc] = 0; tileCap[gr][gc] = 8; budget -= 10
const d = distToRiver(gr, gc)
if (d < 2) eventLog.push({t:'🏘️ Siedlung gebaut — sehr nah am Fluss! Hochwassergefahr! Deich empfohlen.',c:'bad'})
else if (d < 4) eventLog.push({t:'🏘️ Siedlung gebaut — gute Lage. Bewohner ziehen bald ein, wenn Nahrung da ist.',c:'good'})
else eventLog.push({t:'🏘️ Siedlung gebaut — sicher, aber weit weg. Felder in der Nähe anlegen!',c:''})
} else if (t === 'forest') {
if (onRiver) return
if (cur !== 'meadow') return
if (budget < 5) return
land[gr][gc] = 'forest'; budget -= 5
eventLog.push({t:'🌳 Wald gepflanzt — schützt vor Erosion und speichert Grundwasser.',c:'good'})
} else if (t === 'demolish') {
// Begradigung abreissen: auf Fluss klicken
if (onRiver && riverEng[gr] > 0) {
if (budget < 2) return
riverEng[gr] = 0 // Befestigung entfernt → Fluss wandert dort wieder frei
budget -= 2
eventLog.push({t:'🗑️ Begradigung entfernt (Zeile '+( gr+1)+')',c:''})
renderUI(); return
}
if (!['field','settlement','levee','ruin','dead_tree','forest'].includes(cur)) return
if (budget < 2) return
land[gr][gc] = 'meadow'; budget -= 2
}
renderUI()
}
// === CANVAS ===
const $cv = document.getElementById('cv'), ctx = $cv.getContext('2d')
let W=0, H=0, tw=0, th=0, animT=0
function resize() {
const r=$cv.parentElement.getBoundingClientRect(), dpr=devicePixelRatio||1
$cv.width=r.width*dpr;$cv.height=r.height*dpr;ctx.setTransform(dpr,0,0,dpr,0,0)
W=r.width;H=r.height;tw=W/COLS;th=H/ROWS
}
function draw() {
animT += 0.012
const s = Math.min(tw, th) * 0.3
// Background
ctx.fillStyle='#c8d8b0'; ctx.fillRect(0,0,W,H)
// === TILES ===
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++) {
const x=c*tw, y=r*th, tile=land[r][c]
const dist=distToRiver(r,c)
const fi=floodIntensity[r]?.[c]||0
const moisture=Math.max(0,1-dist/5) * 0.2 // permanente Feuchtigkeit nahe Fluss
// Grundfarbe
let bR=155,bG=185,bB=120 // meadow
if(tile==='forest'){bR=55;bG=115;bB=45}
if(tile==='field'){bR=185;bG=170;bB=75}
if(tile==='settlement'){bR=175;bG=150;bB=115}
if(tile==='levee'){bR=135;bG=110;bB=80}
if(tile==='ruin'){bR=125;bG=110;bB=95}
if(tile==='dead_tree'){bR=105;bG=125;bB=75}
// Feuchtigkeit + Hochwasser → Blau-Shift
const blue = Math.min(1, moisture + fi * 0.7)
const fR=bR*(1-blue)+65*blue|0
const fG=bG*(1-blue*0.4)+135*blue*0.4|0
const fB=bB*(1-blue)+185*blue|0
ctx.fillStyle=`rgb(${fR},${fG},${fB})`
ctx.fillRect(x,y,tw+0.5,th+0.5)
// Tile-Objekte
const cx=x+tw/2, cy=y+th/2
if(tile==='forest'){
ctx.fillStyle='#4a3020';ctx.fillRect(cx-s*0.1,cy-s*0.2,s*0.2,s*0.5)
ctx.fillStyle=r<ROWS*0.3?'#1a5a2a':'#2a7a2a'
ctx.beginPath();ctx.arc(cx,cy-s*0.4,s*0.45,0,Math.PI*2);ctx.fill()
}
if(tile==='settlement'){
// 3x3 Raster: Personen aussen, Haus-Emoji in der Mitte
// Personen-Emojis je nach Gesundheit (Feuchtigkeit des Hauses)
const pop=tilePop[r]?.[c]||0
const efs=Math.max(6,Math.min(12,tw*0.28))
ctx.font=`${efs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
const gx=tw*0.32, gy=th*0.32
const healthy=['👩','👨','👧','👦','👩‍🦱','👨‍🦰','👱‍♀️','🧑']
const sick=['😰','🤒','🤢','😷','🤒','😰','🤢','😷']
for(let gr_=0;gr_<3;gr_++)for(let gc_=0;gc_<3;gc_++){
const idx=gr_*3+gc_
if(idx===4){ctx.fillText('🏘️',cx,cy)}
else{
const slotIdx=idx>4?idx-1:idx
if(slotIdx<pop){
// Gesund, leicht krank, schwer krank je nach Feuchtigkeit
let emoji
if(fi>0.35) emoji=sick[slotIdx%8] // schwer krank
else if(fi>0.15) emoji=(slotIdx%2===0)?sick[slotIdx%8]:healthy[slotIdx%8] // manche krank
else emoji=healthy[slotIdx%8] // alle gesund
ctx.fillText(emoji,cx+(gc_-1)*gx,cy+(gr_-1)*gy)
}
}
}
ctx.textBaseline='alphabetic'
}
if(tile==='field'){
// 3x3 Emoji-Raster: Weizen aussen, Status-Emoji in der Mitte
const yi=tileYield[r]?.[c]||0
const dist_=distToRiver(r,c)
let dryPen=0; for(let ii=Math.max(0,r-1);ii<=Math.min(ROWS-1,r+1);ii++){if(riverEng[ii]>0)dryPen=Math.max(dryPen,riverEng[ii]*0.25)}
const moist=Math.max(0,(1-Math.abs(dist_-2.5)/3)-dryPen)
// Status: 💧=nass, 👍=gut, 🥵=trocken
let status; if(fi>0.25) status='💧'; else if(moist>0.4) status='👍'; else status='🥵'
// 8 Positionen um die Mitte: Weizen oder Wasser
// Bei Ueberschwemmung ersetzen Wasser-Emojis den Weizen von unten
const waterSlots = fi > 0.15 ? Math.min(8, Math.round(fi * 12)) : 0
const wheatSlots = Math.min(8, yi) - waterSlots
const grid3 = [] // 9 Positionen: 0-8, Mitte=4
for(let p=0;p<9;p++){
if(p===4){grid3[p]=status; continue}
const slotIdx=p>4?p-1:p // 0-7 fuer die 8 Aussenpositionen
if(slotIdx<Math.max(0,wheatSlots)) grid3[p]='🌾'
else if(slotIdx<Math.max(0,wheatSlots)+waterSlots) grid3[p]='💧'
else grid3[p]=''
}
const efs=Math.max(6,Math.min(12,tw*0.28))
ctx.font=`${efs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
const gx=tw*0.32, gy=th*0.32
for(let gr_=0;gr_<3;gr_++)for(let gc_=0;gc_<3;gc_++){
const idx=gr_*3+gc_
if(grid3[idx]){ctx.fillText(grid3[idx],cx+(gc_-1)*gx,cy+(gr_-1)*gy)}
}
ctx.textBaseline='alphabetic'
}
if(tile==='levee'){
// Deich — wird schmaler bei Erosion (hohe flood intensity)
const w=s*0.8*(1-fi*0.5)
ctx.fillStyle=fi>0.3?'rgba(140,115,85,0.5)':'rgba(140,115,85,0.9)'
ctx.fillRect(cx-w,cy-s*0.1,w*2,s*0.25)
}
if(tile==='ruin'){
ctx.fillStyle='rgba(100,85,70,0.5)';ctx.fillRect(cx-s*0.3,cy-s*0.15,s*0.6,s*0.3)
}
if(tile==='dead_tree'){
ctx.strokeStyle='#2a2a2a';ctx.lineWidth=Math.max(0.5,s*0.1)
ctx.beginPath();ctx.moveTo(cx,cy+s*0.2);ctx.lineTo(cx+s*0.05,cy-s*0.3)
ctx.moveTo(cx+s*0.03,cy-s*0.15);ctx.lineTo(cx-s*0.2,cy-s*0.4)
ctx.stroke()
}
}
// Gitter (dezent)
ctx.strokeStyle='rgba(0,0,0,0.04)';ctx.lineWidth=0.3
for(let r=0;r<=ROWS;r++){ctx.beginPath();ctx.moveTo(0,r*th);ctx.lineTo(W,r*th);ctx.stroke()}
for(let c=0;c<=COLS;c++){ctx.beginPath();ctx.moveTo(c*tw,0);ctx.lineTo(c*tw,H);ctx.stroke()}
// === FLUSS — fliessender Verlauf aus interpolierten Kreisen ===
// Smooth-Animation: riverColSmooth wandert langsam zu riverCol
for (let i = 0; i < ROWS; i++) {
riverColSmooth[i] += (riverCol[i] - riverColSmooth[i]) * 0.06
}
// Virtuelle Punkte oben und unten (damit der Fluss nicht im Nichts beginnt)
const pts = [] // {x (pixel), y (pixel), rad (pixel), eng}
// Virtueller Punkt oberhalb
pts.push({
x: riverColSmooth[0] * tw + tw/2,
y: -th,
rad: riverRadius[0] * tw * 0.45,
eng: riverEng[0]
})
// Alle echten Punkte
for (let i = 0; i < ROWS; i++) {
pts.push({
x: riverColSmooth[i] * tw + tw/2,
y: i * th + th/2,
rad: riverRadius[i] * tw * 0.45,
eng: riverEng[i]
})
}
// Virtueller Punkt unterhalb
pts.push({
x: riverColSmooth[ROWS-1] * tw + tw/2,
y: H + th,
rad: riverRadius[ROWS-1] * tw * 0.45,
eng: riverEng[ROWS-1]
})
// Fluss zeichnen: Kreise entlang der Verbindungen, Radius interpoliert
const totalPts = pts.length
for (let i = 0; i < totalPts - 1; i++) {
const p0 = pts[i], p1 = pts[i+1]
const steps = 12
for (let s = 0; s <= steps; s++) {
const t = s / steps
const px = p0.x + (p1.x - p0.x) * t
const py = p0.y + (p1.y - p0.y) * t
const pr = p0.rad + (p1.rad - p0.rad) * t // Radius interpoliert
const rr = pr + Math.sin(animT * 1.8 + py * 0.01) * pr * 0.06 // leichtes Pulsieren
// Wasser
ctx.fillStyle = 'rgba(50,118,172,0.88)'
ctx.beginPath(); ctx.arc(px, py, rr, 0, Math.PI * 2); ctx.fill()
}
}
// Schimmer/Stroemung ueber den ganzen Fluss
ctx.strokeStyle = `rgba(255,255,255,${0.06 + Math.sin(animT * 2) * 0.03})`; ctx.lineWidth = 0.8
for (let k = 0; k < 8; k++) {
const t = (k / 8 + animT * 0.02) % 1
const idx = t * (totalPts - 1)
const i = Math.floor(idx), frac = idx - i
if (i >= totalPts - 1) continue
const px = pts[i].x + (pts[i+1].x - pts[i].x) * frac
const py = pts[i].y + (pts[i+1].y - pts[i].y) * frac
const pr = pts[i].rad + (pts[i+1].rad - pts[i].rad) * frac
ctx.beginPath(); ctx.moveTo(px - pr * 0.4, py); ctx.lineTo(px + pr * 0.4, py + 1); ctx.stroke()
}
// Uferbefestigung bei begradigten Segmenten
for (let i = 1; i < totalPts - 1; i++) {
const p = pts[i]
const eng = p.eng
if (eng > 0) {
const engColors = ['','rgba(130,100,50,0.5)','rgba(110,110,110,0.6)','rgba(160,160,160,0.7)']
ctx.strokeStyle = engColors[Math.min(3, eng)]
ctx.lineWidth = 1.5 + eng * 1.2
const segH = th * 0.5
ctx.beginPath(); ctx.moveTo(p.x - p.rad - 2, p.y - segH); ctx.lineTo(p.x - p.rad - 2, p.y + segH); ctx.stroke()
ctx.beginPath(); ctx.moveTo(p.x + p.rad + 2, p.y - segH); ctx.lineTo(p.x + p.rad + 2, p.y + segH); ctx.stroke()
}
}
// === WOLKEN ===
for(let i=0;i<3;i++){
const cx=((animT*(10+i*4)+i*250)%(W+120))-60
const cy=10+i*18+Math.sin(animT*0.3+i)*4
const isRain=weather.f>=1.5&&i<2
ctx.fillStyle=isRain?'rgba(90,90,110,0.3)':'rgba(255,255,255,0.35)'
ctx.beginPath();ctx.ellipse(cx,cy,22,8,0,0,Math.PI*2);ctx.fill()
ctx.beginPath();ctx.ellipse(cx-10,cy+2,16,7,0,0,Math.PI*2);ctx.fill()
ctx.beginPath();ctx.ellipse(cx+12,cy+1,18,7,0,0,Math.PI*2);ctx.fill()
if(isRain){ctx.strokeStyle='rgba(80,130,180,0.2)';ctx.lineWidth=0.5
for(let j=0;j<4;j++){const dx=cx-8+j*5;ctx.beginPath();ctx.moveTo(dx,cy+8);ctx.lineTo(dx-1,cy+14+Math.random()*3);ctx.stroke()}}
}
// Floating emojis
const now=performance.now()
for(let i=floats.length-1;i>=0;i--){
const f=floats[i],age=(now-f.t)/1000
if(age>3){floats.splice(i,1);continue}
f.y-=0.0004
ctx.globalAlpha=Math.max(0,1-age/3)
ctx.font=`${12+age*3}px serif`;ctx.textAlign='center'
ctx.fillText(f.e,f.x*W,f.y*H)
ctx.globalAlpha=1
}
requestAnimationFrame(draw)
}
// === RENDER UI ===
function renderUI(){
let inc=0,pop=0,food=0,sett=0,fld=0,for_=0,lev=0,ruin=0
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const t=land[r][c];if(t==='settlement'){sett++;pop+=tilePop[r][c]}
if(t==='field'){fld++;food+=tileYield[r][c];inc+=tileYield[r][c]*0.5}
if(t==='forest')for_++;if(t==='levee')lev++;if(t==='ruin')ruin++
if(t==='settlement')inc+=tilePop[r][c]*0.8
}
document.getElementById('h-bud').textContent=budget
document.getElementById('h-inc').textContent=`+${Math.round(inc)}/J`
document.getElementById('h-yr').textContent=`${year}/${MAX_YEARS}`
document.getElementById('h-wx').textContent=`${weather.e} ${weather.n}`
const avgEng=riverEng.reduce((s,v)=>s+v,0)/ROWS
const floodTotal=floodIntensity.flat().reduce((s,v)=>s+v,0)/(ROWS*COLS)
const clamp=v=>Math.min(100,Math.max(0,Math.round(v)))
const ps=[
{n:`Bevölkerung (${pop})`,i:'👥',v:pop,abs:true}, // absolut, nicht 0-100
{n:'Nahrung',i:'🌾',v:clamp(food*1.5-(weather.f<0.3?20:0))},
{n:'Biodiversität',i:'🦎',v:clamp(for_*4-avgEng*30-(sett+fld)*1.5)},
{n:'Hochwasserschutz',i:'🛡️',v:clamp(15+lev*6+avgEng*8-floodTotal*80)},
{n:'Wirtschaft',i:'💰',v:clamp(pop*3+fld*2-ruin*5)},
]
document.getElementById('params').innerHTML=ps.map(p=>{
const col=p.v<30?'var(--coral)':p.v>55?'var(--moss)':'var(--sand)'
return `<div class="param"><div class="param-i">${p.i}</div><div class="param-inf"><div class="param-n">${p.n}</div><div class="param-bar"><div class="param-bar-f" style="width:${p.v}%;background:${col}"></div></div></div><div class="param-v" style="color:${col}">${p.v}</div></div>`
}).join('')
document.getElementById('log').innerHTML=eventLog.slice(-10).reverse().map(e=>`<div class="log ${e.c}">${e.t}</div>`).join('')
// Wenn selektiertes Tool nicht mehr leistbar → abwaehlen
if(selectedTool){const st=TOOLS.find(t=>t.id===selectedTool);if(st&&budget<st.cost)selectedTool=null}
document.getElementById('tools').innerHTML=TOOLS.map(t=>{
const af=budget>=t.cost;return `<div class="tool ${selectedTool===t.id&&af?'on':''} ${!af?'off':''}" data-t="${t.id}"><span class="tool-e">${t.e}</span><div class="tool-inf"><div class="tool-nm">${t.nm}</div><div class="tool-ds">${t.ds}</div></div><span class="tool-c">💰${t.cost}</span></div>`
}).join('')
document.querySelectorAll('.tool').forEach(b=>b.addEventListener('click',()=>{selectedTool=selectedTool===b.dataset.t?null:b.dataset.t;renderUI()}))
// === GRAPH ===
drawGraph()
}
function drawGraph(){
const gc=document.getElementById('graph')
if(!gc||history.length<2)return
const gctx=gc.getContext('2d')
const rect=gc.parentElement.getBoundingClientRect()
const dpr=devicePixelRatio||1
gc.width=rect.width*dpr;gc.height=90*dpr;gctx.setTransform(dpr,0,0,dpr,0,0)
const gW=rect.width,gH=90
gctx.clearRect(0,0,gW,gH)
// Hintergrund-Zonen (Toleranzen)
gctx.fillStyle='rgba(180,60,50,0.06)';gctx.fillRect(0,gH*0.75,gW,gH*0.25) // Gefahrenzone unten
gctx.fillStyle='rgba(90,140,90,0.06)';gctx.fillRect(0,0,gW,gH*0.35) // Gute Zone oben
gctx.fillStyle='rgba(200,180,80,0.06)';gctx.fillRect(0,gH*0.35,gW,gH*0.4) // Mittelzone
// Trennlinien
gctx.strokeStyle='rgba(0,0,0,0.06)';gctx.lineWidth=0.5;gctx.setLineDash([3,3])
gctx.beginPath();gctx.moveTo(0,gH*0.35);gctx.lineTo(gW,gH*0.35);gctx.stroke()
gctx.beginPath();gctx.moveTo(0,gH*0.75);gctx.lineTo(gW,gH*0.75);gctx.stroke()
gctx.setLineDash([])
// Labels
gctx.font='7px Inter,sans-serif';gctx.fillStyle='rgba(0,0,0,0.2)';gctx.textAlign='right'
gctx.fillText('gut',gW-3,gH*0.2);gctx.fillText('kritisch',gW-3,gH*0.88)
const lines=[
{key:'pop',color:'#6a6aaa',label:'👥'},
{key:'food',color:'#8a9a30',label:'🌾'},
{key:'bio',color:'#3a8a3a',label:'🦎'},
{key:'safety',color:'#4a7c8a',label:'🛡️'},
{key:'economy',color:'#c4a35a',label:'💰'},
]
// Auto-scale: finde max ueber alle Werte
let maxVal=100
for(const h of history)for(const l of lines){const v=h[l.key]||0;if(v>maxVal)maxVal=v}
maxVal=Math.max(100,maxVal*1.1)
const pad=2,plotW=gW-pad*2,plotH=gH-pad*2
for(const line of lines){
gctx.strokeStyle=line.color;gctx.lineWidth=1.2;gctx.beginPath()
history.forEach((h,i)=>{
const x=pad+(i/(Math.max(1,MAX_YEARS-1)))*plotW
const y=pad+plotH-(((h[line.key]||0)/maxVal)*plotH)
i===0?gctx.moveTo(x,y):gctx.lineTo(x,y)
})
gctx.stroke()
// Label am Ende
if(history.length>0){
const last=history[history.length-1]
const lx=pad+((history.length-1)/(Math.max(1,MAX_YEARS-1)))*plotW
const ly=pad+plotH-(((last[line.key]||0)/maxVal)*plotH)
gctx.font='8px serif';gctx.textAlign='left';gctx.fillStyle=line.color
gctx.fillText(line.label,lx+3,ly+3)
}
}
}
// === EVENTS ===
const $cv2=$cv
$cv2.addEventListener('click',e=>{
const r=$cv2.getBoundingClientRect()
const gc=Math.floor((e.clientX-r.left)/tw), gr=Math.floor((e.clientY-r.top)/th)
applyAt(gr,gc)
})
$cv2.addEventListener('mousemove',e=>{
const r=$cv2.getBoundingClientRect()
const gc=Math.floor((e.clientX-r.left)/tw), gr=Math.floor((e.clientY-r.top)/th)
if(gr>=0&&gr<ROWS&&gc>=0&&gc<COLS){
const t=land[gr][gc],dist=distToRiver(gr,gc).toFixed(1)
const onR=isRiverTile(gr,gc)
let info=onR?`🌊 Fluss (Befestigung: ${riverEng[gr]})`:
{meadow:'🌱 Wiese',forest:'🌳 Wald',field:`🌾 Feld (Ertrag: ${tileYield[gr]?.[gc]||0})`,
settlement:`🏠 Siedlung (${tilePop[gr]?.[gc]||0}/8)`,levee:'🧱 Deich',ruin:'🏚️ Ruine',dead_tree:'🪵 Toter Baum'}[t]||t
info+=`<br>Flussabstand: ${dist}`
document.getElementById('info').innerHTML=info
}
})
document.querySelectorAll('.spd button').forEach(b=>b.addEventListener('click',()=>{
speed=parseInt(b.dataset.s);document.querySelectorAll('.spd button').forEach(x=>x.classList.remove('on'));b.classList.add('on')
}))
function showEnd(){
document.getElementById('end-t').textContent='100 Jahre sind um!'
let p=0;for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++)p+=tilePop[r][c]
document.getElementById('end-p').textContent=`💰${budget} | 👥${p} Bewohner | 🏠${land.flat().filter(t=>t==='settlement').length} Siedlungen`
document.getElementById('ov-end').classList.remove('hidden')
}
document.getElementById('btn-go').addEventListener('click',()=>{document.getElementById('ov-intro').classList.add('hidden');renderUI()})
document.getElementById('btn-again').addEventListener('click',()=>{document.getElementById('ov-end').classList.add('hidden');initGame();renderUI()})
document.getElementById('btn-reset').addEventListener('click',()=>{if(confirm('Neu?')){initGame();renderUI()}})
// === GAME LOOP ===
let lastT=0
function loop(ts){if(!lastT)lastT=ts;const dt=ts-lastT;lastT=ts
if(speed>0&&!gameOver){tickAccum+=dt*speed;while(tickAccum>=MS_PER_TICK){tickAccum-=MS_PER_TICK;tick()}}
requestAnimationFrame(loop)}
initGame();resize();window.addEventListener('resize',resize);renderUI();draw();requestAnimationFrame(loop)
</script>
</body>
</html>
+1239
View File
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Klimawächter</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --bg2: #efeee9; --white: #fff;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
--danger: #b04a3a;
}
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; }
.nav { padding:.6rem 1.2rem; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid rgba(0,0,0,.05); background:var(--white); }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:1rem; color:var(--fjord); text-decoration:none; }
.nav-brand img { width:28px; height:28px; border-radius:6px; }
.nav-actions { display:flex; gap:.4rem; }
.nav-btn { padding:.4rem .8rem; border:1px solid rgba(0,0,0,.08); border-radius:8px; background:var(--white); color:var(--text2); font-size:.78rem; font-weight:600; cursor:pointer; text-decoration:none; }
.nav-btn:hover { background:var(--fjord-l); border-color:var(--fjord); color:var(--fjord); }
/* === MAIN LAYOUT === */
.game-layout { display:grid; grid-template-columns:280px 1fr 280px; gap:.8rem; padding:.8rem; max-width:1400px; margin:0 auto; align-items:start; }
.panel { background:var(--white); border-radius:12px; border:1px solid rgba(0,0,0,.05); padding:.9rem; box-shadow:0 2px 8px rgba(0,0,0,.03); }
.panel-h { font-size:.66rem; font-weight:700; color:var(--fjord); text-transform:uppercase; letter-spacing:.06em; margin-bottom:.5rem; }
/* === LEFT: Resources & Goals === */
.left { display:flex; flex-direction:column; gap:.8rem; }
.resource { display:flex; align-items:center; gap:.6rem; padding:.4rem .2rem; border-bottom:1px solid rgba(0,0,0,.04); }
.resource:last-child { border-bottom:none; }
.resource-icon { font-size:1.4rem; width:28px; text-align:center; }
.resource-info { flex:1; min-width:0; }
.resource-name { font-size:.7rem; color:var(--text3); font-weight:500; }
.resource-val { font-size:.95rem; font-weight:700; color:var(--text); }
.resource-trend { font-size:.65rem; padding:1px 5px; border-radius:4px; font-weight:600; }
.trend-up { background:var(--moss-l); color:var(--moss); }
.trend-down { background:var(--coral-l); color:var(--coral); }
.goals .goal { padding:.4rem 0; border-bottom:1px solid rgba(0,0,0,.04); }
.goals .goal:last-child { border:0; }
.goal-row { display:flex; align-items:center; gap:.4rem; font-size:.78rem; }
.goal-check { width:14px; height:14px; border:1.5px solid var(--text3); border-radius:3px; display:flex; align-items:center; justify-content:center; font-size:.6rem; flex-shrink:0; }
.goal-check.done { background:var(--moss); border-color:var(--moss); color:#fff; }
.goal-bar { height:3px; background:#e8e5dc; border-radius:2px; margin-top:.3rem; overflow:hidden; }
.goal-bar-fill { height:100%; background:var(--moss); transition:width .3s; }
/* === CENTER: Game Canvas + Controls === */
.center { display:flex; flex-direction:column; gap:.6rem; }
.canvas-wrap { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.05); overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,.05); position:relative; }
/* Tutorial Overlay */
.tutorial-overlay { position:absolute; inset:0; background:rgba(15,30,40,.85); backdrop-filter:blur(8px); display:flex; align-items:center; justify-content:center; z-index:10; padding:1.5rem; }
.tutorial-card { background:var(--white); border-radius:16px; padding:1.5rem 1.8rem; max-width:480px; box-shadow:0 20px 60px rgba(0,0,0,.3); }
.tutorial-card h3 { font-size:1.2rem; font-weight:800; margin-bottom:.6rem; color:var(--fjord); }
.tutorial-card p { font-size:.88rem; color:var(--text2); white-space:pre-line; margin-bottom:1rem; }
.tutorial-card .progress-dots { display:flex; gap:.3rem; justify-content:center; margin:.8rem 0; }
.dot { width:8px; height:8px; border-radius:50%; background:#dadad0; }
.dot.active { background:var(--fjord); }
.tut-btn { width:100%; padding:.7rem; background:var(--fjord); color:#fff; border:none; border-radius:10px; font-weight:700; font-size:.9rem; cursor:pointer; }
.tut-btn:hover { background:var(--fjord-d); }
/* Speed controls */
.speed-bar { display:flex; gap:.3rem; padding:.5rem; background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); }
.speed-btn { flex:1; padding:.5rem; border:none; background:transparent; color:var(--text2); font-size:.8rem; font-weight:600; cursor:pointer; border-radius:6px; }
.speed-btn:hover { background:#f0eeea; }
.speed-btn.active { background:var(--fjord); color:#fff; }
/* Graphs */
.graphs { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
.graph-card { background:var(--white); border-radius:10px; border:1px solid rgba(0,0,0,.05); padding:.6rem; }
.graph-title { font-size:.65rem; color:var(--text3); font-weight:600; text-transform:uppercase; letter-spacing:.06em; margin-bottom:.2rem; }
.graph-svg { width:100%; height:50px; }
/* === RIGHT: Measures Shop & Events === */
.right { display:flex; flex-direction:column; gap:.8rem; }
.measure-card { display:flex; gap:.6rem; padding:.6rem; border-radius:10px; border:1px solid rgba(0,0,0,.06); margin-bottom:.4rem; cursor:pointer; transition:all .2s; background:var(--white); }
.measure-card:hover { border-color:var(--fjord); background:var(--fjord-l); }
.measure-card.disabled { opacity:.5; cursor:not-allowed; }
.measure-card.disabled:hover { border-color:rgba(0,0,0,.06); background:var(--white); }
.measure-icon { font-size:1.5rem; width:32px; text-align:center; }
.measure-info { flex:1; min-width:0; }
.measure-name { font-size:.8rem; font-weight:700; }
.measure-desc { font-size:.66rem; color:var(--text3); line-height:1.3; }
.measure-stats { display:flex; gap:.3rem; flex-wrap:wrap; margin-top:.2rem; }
.measure-stat { font-size:.6rem; padding:1px 5px; border-radius:4px; background:#f0eeea; color:var(--text3); font-weight:600; }
.measure-cost { background:var(--sand-l); color:var(--sand); }
.measure-co2 { background:var(--moss-l); color:var(--moss); }
.measure-protect { background:var(--fjord-l); color:var(--fjord); }
.measure-count { font-size:.62rem; padding:1px 5px; border-radius:4px; background:var(--fjord); color:#fff; font-weight:700; }
.events-list { max-height:160px; overflow-y:auto; }
.event { padding:.4rem .5rem; border-radius:8px; margin-bottom:.3rem; font-size:.74rem; line-height:1.3; }
.event-info { background:#f0eeea; color:var(--text2); }
.event-success { background:var(--moss-l); color:var(--moss); }
.event-warning { background:var(--sand-l); color:var(--sand); }
.event-danger { background:var(--coral-l); color:var(--danger); }
/* === END SCREEN === */
.end-screen { position:fixed; inset:0; background:rgba(15,30,40,.9); backdrop-filter:blur(10px); display:flex; align-items:center; justify-content:center; z-index:100; padding:2rem; }
.end-card { background:var(--white); border-radius:20px; padding:2rem 2.5rem; max-width:500px; text-align:center; box-shadow:0 30px 80px rgba(0,0,0,.4); }
.end-icon { font-size:4rem; margin-bottom:.5rem; }
.end-card h2 { font-size:1.8rem; font-weight:900; letter-spacing:-.02em; margin-bottom:.3rem; }
.end-card .end-summary { color:var(--text2); margin-bottom:1rem; }
.end-stats { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; margin:1rem 0; }
.end-stat { background:#f7f6f3; border-radius:10px; padding:.8rem; }
.end-stat-val { font-size:1.4rem; font-weight:800; color:var(--fjord); }
.end-stat-lbl { font-size:.7rem; color:var(--text3); text-transform:uppercase; }
/* RESPONSIVE */
@media (max-width: 1100px) {
.game-layout { grid-template-columns:1fr; }
.left, .right { order:2; }
}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim · Klimawächter</a>
<div class="nav-actions">
<button class="nav-btn" id="btn-save">💾 Speichern</button>
<button class="nav-btn" id="btn-load">📂 Laden</button>
<button class="nav-btn" id="btn-finish">⏹ Beenden</button>
<button class="nav-btn" id="btn-reset" title="Stand löschen und neu starten">🔄 Reset</button>
<a href="index.html" class="nav-btn">← Zurück</a>
</div>
</nav>
<div class="game-layout">
<!-- LEFT: Resources, Goals -->
<aside class="left">
<div class="panel">
<div class="panel-h">📊 Status</div>
<div id="resources"></div>
</div>
<div class="panel goals">
<div class="panel-h">🎯 Ziele</div>
<div id="goals"></div>
</div>
</aside>
<!-- CENTER: Canvas + Controls + Graphs -->
<main class="center">
<div class="canvas-wrap" id="canvas-wrap">
<div class="tutorial-overlay" id="tutorial">
<div class="tutorial-card">
<h3 id="tut-title"></h3>
<p id="tut-text"></p>
<div class="progress-dots" id="tut-dots"></div>
<button class="tut-btn" id="tut-next">Weiter →</button>
</div>
</div>
</div>
<div class="speed-bar">
<button class="speed-btn" data-speed="0"></button>
<button class="speed-btn active" data-speed="1">▶ Normal</button>
<button class="speed-btn" data-speed="2">⏩ Schnell</button>
<button class="speed-btn" data-speed="4">⏭ Sehr schnell</button>
</div>
<div class="graphs">
<div class="graph-card">
<div class="graph-title">🌫 CO₂ über Zeit</div>
<svg class="graph-svg" id="graph-co2"></svg>
</div>
<div class="graph-card">
<div class="graph-title">🌡 Temperatur über Zeit</div>
<svg class="graph-svg" id="graph-temp"></svg>
</div>
<div class="graph-card">
<div class="graph-title">💰 Budget über Zeit</div>
<svg class="graph-svg" id="graph-budget"></svg>
</div>
<div class="graph-card">
<div class="graph-title">🌊 Meeresspiegel über Zeit</div>
<svg class="graph-svg" id="graph-sea"></svg>
</div>
</div>
</main>
<!-- RIGHT: Measures Shop, Events -->
<aside class="right">
<div class="panel">
<div class="panel-h">🏗 Maßnahmen kaufen</div>
<div id="measures"></div>
</div>
<div class="panel">
<div class="panel-h">📰 Ereignisse</div>
<div class="events-list" id="events"></div>
</div>
</aside>
</div>
<!-- End screen template -->
<div id="end-screen-host"></div>
<script type="module">
import { KlimawaechterGame, MEASURES } from './src/sims/sim-05-treibhaus/game.ts'
import { KlimawaechterRenderer } from './src/sims/sim-05-treibhaus/game-renderer.ts'
const game = new KlimawaechterGame()
let renderer = null
let endShown = false
// === ELEMENTS ===
const tutorialEl = document.getElementById('tutorial')
const tutTitle = document.getElementById('tut-title')
const tutText = document.getElementById('tut-text')
const tutDots = document.getElementById('tut-dots')
const tutNext = document.getElementById('tut-next')
const resourcesEl = document.getElementById('resources')
const goalsEl = document.getElementById('goals')
const measuresEl = document.getElementById('measures')
const eventsEl = document.getElementById('events')
const canvasWrap = document.getElementById('canvas-wrap')
// === RENDER MEASURES SHOP ===
function renderMeasures() {
const budget = game.getResource('budget')
measuresEl.innerHTML = MEASURES.map(m => {
const count = game.getMeasureCount(m.id)
const canAfford = budget >= m.cost
return `
<div class="measure-card ${canAfford ? '' : 'disabled'}" data-id="${m.id}">
<div class="measure-icon">${m.emoji}</div>
<div class="measure-info">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="measure-name">${m.name}</div>
${count > 0 ? `<span class="measure-count">×${count}</span>` : ''}
</div>
<div class="measure-desc">${m.description}</div>
<div class="measure-stats">
<span class="measure-stat measure-cost">💰 ${m.cost} €</span>
${m.co2Reduction > 0 ? `<span class="measure-stat measure-co2">${m.co2Reduction} CO₂/J</span>` : ''}
${m.protection > 0 ? `<span class="measure-stat measure-protect">🛡 +${m.protection} cm</span>` : ''}
<span class="measure-stat">⚙ ${m.upkeep} €/J</span>
</div>
</div>
</div>
`
}).join('')
measuresEl.querySelectorAll('.measure-card').forEach(el => {
el.addEventListener('click', () => {
if (el.classList.contains('disabled')) return
const id = el.dataset.id
game.buyMeasure(id)
})
})
}
// === RENDER RESOURCES ===
function renderResources() {
const resources = game.getResourcesArray()
resourcesEl.innerHTML = resources.map(r => `
<div class="resource">
<div class="resource-icon">${r.icon}</div>
<div class="resource-info">
<div class="resource-name">${r.name}</div>
<div class="resource-val">${r.format ? r.format(r.current) : r.current}</div>
</div>
</div>
`).join('')
}
// === RENDER GOALS ===
function renderGoals() {
const snap = game.getSnapshot()
goalsEl.innerHTML = snap.goals.map((g, i) => {
const goal = game.goals?.[i] || { title: g.id, description: '' }
return `
<div class="goal">
<div class="goal-row">
<div class="goal-check ${g.achieved ? 'done' : ''}">${g.achieved ? '✓' : ''}</div>
<div>${goal.title || g.id}</div>
</div>
<div class="goal-bar"><div class="goal-bar-fill" style="width:${g.progress}%"></div></div>
</div>
`
}).join('')
}
// === RENDER EVENTS ===
function renderEvents() {
const events = game.getEvents(8)
if (events.length === 0) {
eventsEl.innerHTML = '<div style="font-size:.72rem;color:var(--text3);text-align:center;padding:.5rem">Noch keine Ereignisse</div>'
return
}
eventsEl.innerHTML = events.map(e => `
<div class="event event-${e.severity}">
<strong>Jahr ${e.tick}:</strong> ${e.text}
</div>
`).join('')
}
// === RENDER GRAPHS ===
function renderGraphs() {
const snap = game.getSnapshot()
const tl = snap.timeline
if (tl.length === 0) return
drawGraph('graph-co2', tl.map(e => ({x: e.tick, y: e.values.co2 || 425})), 280, 800, '#8a7868')
drawGraph('graph-temp', tl.map(e => ({x: e.tick, y: e.values.temperature || 15})), 14, 22, '#c07a6b')
drawGraph('graph-budget', tl.map(e => ({x: e.tick, y: e.values.budget || 0})), -500, 3000, '#5a8a5e')
drawGraph('graph-sea', tl.map(e => ({x: e.tick, y: e.values.sealevel || 0})), 0, 200, '#4a7c8a')
}
function drawGraph(id, points, yMin, yMax, color) {
const svg = document.getElementById(id)
const w = svg.clientWidth || 200
const h = 50
svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
if (points.length < 2) {
svg.innerHTML = ''
return
}
const maxX = Math.max(30, points[points.length - 1].x)
const path = points.map((p, i) => {
const x = (p.x / maxX) * w
const y = h - ((p.y - yMin) / (yMax - yMin)) * h
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${Math.max(2, Math.min(h-2, y)).toFixed(1)}`
}).join(' ')
const lastVal = points[points.length - 1].y
svg.innerHTML = `
<path d="${path}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" />
<text x="${w-3}" y="12" text-anchor="end" font-size="9" font-weight="700" fill="${color}">${lastVal.toFixed(lastVal > 50 ? 0 : 1)}</text>
`
}
// === TUTORIAL ===
function renderTutorial() {
const step = game.getCurrentTutorialStep()
if (!step || game.getSnapshot().state !== 'tutorial') {
tutorialEl.style.display = 'none'
if (!renderer) {
renderer = new KlimawaechterRenderer(canvasWrap, game)
renderer.start()
}
return
}
tutorialEl.style.display = 'flex'
tutTitle.textContent = step.title
tutText.textContent = step.text
const totalSteps = 4
tutDots.innerHTML = ''
for (let i = 0; i < totalSteps; i++) {
const d = document.createElement('div')
d.className = 'dot' + (i === game.getSnapshot().tutorialStep ? ' active' : '')
tutDots.appendChild(d)
}
tutNext.textContent = game.getSnapshot().tutorialStep === totalSteps - 1 ? 'Simulation starten 🚀' : 'Weiter →'
}
tutNext.addEventListener('click', () => {
game.nextTutorialStep()
renderAll()
})
// === SPEED CONTROL ===
document.querySelectorAll('.speed-btn').forEach(btn => {
btn.addEventListener('click', () => {
const speed = parseInt(btn.dataset.speed)
game.setSpeed(speed)
document.querySelectorAll('.speed-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
})
})
// === SAVE / LOAD / FINISH ===
document.getElementById('btn-save').addEventListener('click', () => {
localStorage.setItem('klimawaechter-save', game.serialize())
alert('💾 Stand gespeichert')
})
document.getElementById('btn-load').addEventListener('click', () => {
const save = localStorage.getItem('klimawaechter-save')
if (!save) { alert('Kein Stand vorhanden'); return }
if (game.deserialize(save)) {
renderAll()
alert('📂 Stand geladen')
}
})
document.getElementById('btn-finish').addEventListener('click', () => {
game.finish()
showEndScreen()
})
// === END SCREEN ===
function showEndScreen() {
if (endShown) return
endShown = true
const snap = game.getSnapshot()
const won = snap.state === 'won'
const lost = snap.state === 'lost'
const icon = won ? '🏆' : lost ? '💔' : '🏁'
const title = won ? 'Du hast gewonnen!' : lost ? 'Simulation gescheitert' : 'Simulation beendet'
const summary = won
? 'Glückwunsch! Du hast deine Stadt bis zum Jahr 2100 erfolgreich durch die Klimakrise geführt.'
: lost
? snap.resources.budget < 0 ? 'Deine Stadt ist pleite gegangen.' : 'Der steigende Meeresspiegel hat zu viele Häuser zerstört.'
: `Du hast im Jahr ${2025 + snap.tick} beendet.`
const html = `
<div class="end-screen">
<div class="end-card">
<div class="end-icon">${icon}</div>
<h2>${title}</h2>
<p class="end-summary">${summary}</p>
<div class="end-stats">
<div class="end-stat"><div class="end-stat-val">${snap.tick}</div><div class="end-stat-lbl">Jahre (bis ${2025 + snap.tick})</div></div>
<div class="end-stat"><div class="end-stat-val">${Math.round(snap.resources.budget)} €</div><div class="end-stat-lbl">Budget übrig</div></div>
<div class="end-stat"><div class="end-stat-val">${snap.resources.temperature.toFixed(1)}°C</div><div class="end-stat-lbl">Endtemperatur</div></div>
<div class="end-stat"><div class="end-stat-val">${Math.round(snap.resources.flooded)}%</div><div class="end-stat-lbl">Überflutet</div></div>
</div>
<button class="tut-btn" onclick="location.reload()">🔄 Nochmal spielen</button>
</div>
</div>
`
document.getElementById('end-screen-host').innerHTML = html
}
// === MAIN RENDER ===
function renderAll() {
renderResources()
renderGoals()
renderMeasures()
renderEvents()
renderGraphs()
renderTutorial()
const snap = game.getSnapshot()
if ((snap.state === 'won' || snap.state === 'lost') && !endShown) {
setTimeout(showEndScreen, 800)
}
}
// Subscribe to game updates
game.subscribe(renderAll)
// Initial render
renderAll()
</script>
</body>
</html>
+574
View File
@@ -0,0 +1,574 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Geografie erleben</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --bg2: #efeee9; --white: #fff;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #9a9a9a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
--r: 20px; --rs: 12px;
}
html { scroll-behavior: smooth; }
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); overflow-x: hidden; -webkit-font-smoothing: antialiased; line-height: 1.6; }
/* ===== NAV ===== */
.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; padding: .7rem 0; transition: all .4s; }
.nav.scrolled { backdrop-filter: blur(20px); background: rgba(247,246,243,0.9); box-shadow: 0 1px 0 rgba(0,0,0,0.04); }
.nav .w { max-width: 1200px; margin: 0 auto; padding: 0 2rem; display: flex; justify-content: space-between; align-items: center; }
.nav-brand { display: flex; align-items: center; gap: .5rem; font-weight: 800; font-size: 1.05rem; color: var(--fjord); text-decoration: none; }
.nav-brand img { width: 30px; height: 30px; border-radius: 7px; }
.nav-links { display: flex; gap: .2rem; align-items: center; }
.nav-links a { color: var(--text2); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; font-size: .82rem; font-weight: 500; transition: all .2s; }
.nav-links a:hover { background: rgba(0,0,0,0.04); }
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .55rem 1.3rem; border-radius: var(--rs); font-weight: 600; font-size: .82rem; text-decoration: none; border: none; cursor: pointer; transition: all .25s cubic-bezier(.22,1,.36,1); }
.btn-p { background: var(--fjord); color: #fff; box-shadow: 0 2px 10px rgba(74,124,138,.2); }
.btn-p:hover { background: var(--fjord-d); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(74,124,138,.25); }
.btn-o { background: transparent; color: var(--fjord); border: 1.5px solid rgba(74,124,138,.3); }
.btn-o:hover { border-color: var(--fjord); background: var(--fjord-l); }
/* ===== HERO ===== */
.hero { height: 100vh; min-height: 700px; position: relative; overflow: hidden; display: flex; align-items: center; }
.hero canvas { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; }
.hero-content { position: relative; z-index: 2; max-width: 1200px; margin: 0 auto; padding: 0 2rem; width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: center; }
.hero-text h1 { font-size: clamp(2.2rem, 4.5vw, 3.8rem); font-weight: 900; line-height: 1.05; letter-spacing: -.04em; margin-bottom: .8rem; }
.hero-text h1 em { font-style: normal; color: var(--fjord); }
.hero-text .lead { font-size: 1.05rem; color: var(--text2); max-width: 420px; margin-bottom: 1.5rem; }
.hero-btns { display: flex; gap: .6rem; }
.hero-visual { display: flex; justify-content: center; perspective: 800px; }
.hero-card { width: 340px; background: var(--white); border-radius: var(--r); box-shadow: 0 20px 60px rgba(0,0,0,.1); overflow: hidden; transform: rotateY(-8deg) rotateX(3deg); transition: transform .5s cubic-bezier(.22,1,.36,1); }
.hero-card:hover { transform: rotateY(0) rotateX(0); }
.hero-card img { width: 100%; aspect-ratio: 1; object-fit: cover; }
.hero-card-body { padding: 1rem; }
.hero-card-body h3 { font-size: .9rem; font-weight: 700; }
.hero-card-body p { font-size: .75rem; color: var(--text3); }
.hero-mini { position: absolute; background: var(--white); border-radius: var(--rs); box-shadow: 0 8px 30px rgba(0,0,0,.08); padding: .6rem .9rem; font-size: .75rem; font-weight: 600; }
.hero-mini-1 { top: 10%; right: -10px; transform: rotate(3deg); animation: bobA 4s ease-in-out infinite; }
.hero-mini-2 { bottom: 15%; left: -20px; transform: rotate(-2deg); animation: bobB 5s ease-in-out infinite; }
@keyframes bobA { 0%,100% { transform: rotate(3deg) translateY(0); } 50% { transform: rotate(3deg) translateY(-8px); } }
@keyframes bobB { 0%,100% { transform: rotate(-2deg) translateY(0); } 50% { transform: rotate(-2deg) translateY(6px); } }
/* ===== TICKER ===== */
.ticker { background: var(--fjord); padding: .9rem 0; overflow: hidden; white-space: nowrap; }
.ticker-inner { display: inline-flex; animation: scroll 35s linear infinite; }
.ticker-item { color: rgba(255,255,255,.85); font-weight: 600; font-size: .8rem; padding: 0 1.8rem; }
@keyframes scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
/* ===== SECTION UTILS ===== */
.sec { padding: 5rem 0; }
.w { max-width: 1200px; margin: 0 auto; padding: 0 2rem; }
.label { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--fjord); }
.sec-title { font-size: 2.2rem; font-weight: 900; letter-spacing: -.03em; margin: .3rem 0; }
.sec-sub { color: var(--text2); max-width: 460px; font-size: .95rem; }
/* ===== STATS ===== */
.stats { margin-top: 2rem; position: relative; z-index: 3; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; }
.stat { background: var(--white); border-radius: var(--r); padding: 1.3rem; text-align: center; box-shadow: 0 6px 20px rgba(0,0,0,.05); border: 1px solid rgba(0,0,0,.04); }
.stat-n { font-size: 2rem; font-weight: 900; letter-spacing: -.03em; }
.stat-l { font-size: .72rem; color: var(--text3); margin-top: .1rem; }
/* ===== FLOATING EMOJIS ===== */
.emoji-layer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; overflow: hidden; }
.emoji-float { position: absolute; opacity: .06; animation: emojiDrift linear infinite; }
@keyframes emojiDrift {
0% { transform: translateY(105vh) rotate(0deg); }
100% { transform: translateY(-10vh) rotate(360deg); }
}
/* ===== SIM CARDS GRID ===== */
.sim-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 2rem; }
.sim-card { background: var(--white); border-radius: 16px; overflow: hidden; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.06); border: 1.5px solid rgba(0,0,0,.05); transition: all .35s cubic-bezier(.22,1,.36,1); cursor: pointer; padding: .6rem; }
.sim-card:hover { transform: translateY(-5px); box-shadow: 0 12px 35px rgba(74,124,138,.12); border-color: rgba(74,124,138,.2); }
.sim-card-img { height: 150px; overflow: hidden; position: relative; border-radius: 10px; }
.sim-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s; }
.sim-card:hover .sim-card-img img { transform: scale(1.06); }
.sim-card-body { padding: .8rem .4rem .4rem; }
.sim-card-body h3 { font-size: .88rem; font-weight: 700; margin-bottom: .2rem; }
.sim-card-body p { font-size: .75rem; color: var(--text2); margin-bottom: .5rem; }
.sim-card-tags { display: flex; gap: .3rem; flex-wrap: wrap; }
.sim-tag { font-size: .62rem; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
.st1 { background: var(--fjord-l); color: var(--fjord); }
.st2 { background: var(--moss-l); color: var(--moss); }
.st3 { background: var(--sand-l); color: var(--sand); }
.st4 { background: var(--coral-l); color: var(--coral); }
.st-time { background: #f0eeea; color: var(--text3); }
/* ===== FEATURES ===== */
.feat-sec { background: var(--bg2); position: relative; }
.feat-sec::before { content: ''; position: absolute; top: -60px; left: 0; right: 0; height: 120px; background: var(--bg2); transform: skewY(-2.5deg); z-index: -1; }
.feat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 2rem; }
.feat { background: var(--white); border-radius: var(--r); padding: 1.6rem; border: 1px solid rgba(0,0,0,.03); transition: all .3s; }
.feat:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(0,0,0,.06); }
.feat-ico { font-size: 1.8rem; margin-bottom: .6rem; }
.feat h3 { font-size: .88rem; font-weight: 700; margin-bottom: .2rem; }
.feat p { font-size: .78rem; color: var(--text2); }
/* ===== KLASSEN ===== */
.kl-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-top: 2rem; }
.kl { border-radius: var(--r); padding: 1.8rem 1.3rem; color: #fff; transition: all .3s; position: relative; overflow: hidden; }
.kl:hover { transform: translateY(-5px) rotate(0deg) !important; }
.kl h3 { font-size: 1.3rem; font-weight: 800; }
.kl .ksub { font-size: .82rem; opacity: .9; margin: .2rem 0 .6rem; }
.kl .kdet { font-size: .75rem; opacity: .7; line-height: 1.5; }
.kl::after { content: ''; position: absolute; top: -30px; right: -30px; width: 100px; height: 100px; background: rgba(255,255,255,.08); border-radius: 50%; }
.k1 { background: linear-gradient(135deg, #4a7c8a, #5a9aaa); transform: rotate(-1.2deg); }
.k2 { background: linear-gradient(135deg, #5a8a5e, #6aaa6e); transform: rotate(.8deg); }
.k3 { background: linear-gradient(135deg, #c4a35a, #d4b36a); transform: rotate(-.6deg); }
.k4 { background: linear-gradient(135deg, #c07a6b, #d08a7b); transform: rotate(1deg); }
/* ===== CTA ===== */
.cta { text-align: center; padding: 5rem 2rem; }
.cta h2 { font-size: 2.5rem; font-weight: 900; letter-spacing: -.03em; }
.cta h2 em { font-style: normal; color: var(--fjord); }
.cta p { color: var(--text2); margin: .5rem 0 1.5rem; }
/* ===== FOOTER ===== */
footer { padding: 1.5rem; text-align: center; color: var(--text3); font-size: .72rem; border-top: 1px solid rgba(0,0,0,.04); }
footer a { color: var(--fjord); text-decoration: none; }
/* ===== SCROLL REVEAL ===== */
.reveal { opacity: 0; transform: translateY(40px); transition: all .8s cubic-bezier(.22,1,.36,1); }
.reveal.visible { opacity: 1; transform: translateY(0); }
/* ===== RESPONSIVE ===== */
@media (max-width: 900px) {
.hero-content { grid-template-columns: 1fr; text-align: center; }
.hero-text .lead { margin: 0 auto .8rem; }
.hero-btns { justify-content: center; }
.hero-visual { display: none; }
.stats-grid, .kl-grid { grid-template-columns: repeat(2, 1fr); }
.feat-grid { grid-template-columns: 1fr; }
.sim-grid { grid-template-columns: repeat(2, 1fr) !important; }
.nav-links { display: none; }
}
@media (max-width: 550px) {
.sim-grid { grid-template-columns: 1fr !important; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav class="nav" id="nav">
<div class="w">
<a href="#" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim</a>
<div class="nav-links">
<a href="#sims">Simulationen</a>
<a href="#features">Features</a>
<a href="#klassen">Klassen</a>
<a href="#" class="btn btn-p" style="margin-left:.3rem">Anmelden</a>
</div>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<canvas id="sc"></canvas>
<div class="hero-content">
<div class="hero-text">
<h1>Geografie<br><em>erleben.</em><br>Nicht nur lesen.</h1>
<p class="lead">Interaktive Simulationen für den GW-Unterricht. Klimawandel verstehen. Erdbeben simulieren. Städte planen. Zukunft gestalten.</p>
<div class="hero-btns">
<a href="#sims" class="btn btn-p">🚀 Entdecken</a>
<a href="#features" class="btn btn-o">Für Lehrkräfte</a>
</div>
</div>
<div class="hero-visual">
<div class="hero-card">
<img src="assets/img/card-climate.png" alt="">
<div class="hero-card-body">
<h3>🌡️ Treibhauseffekt-Simulator</h3>
<p>CO₂ verändern → Temperatur beobachten. 20 min.</p>
</div>
</div>
<div class="hero-mini hero-mini-1">⚡ 24 Module</div>
<div class="hero-mini hero-mini-2">📊 330 Quellen</div>
</div>
</div>
</section>
<!-- TICKER -->
<div class="ticker"><div class="ticker-inner">
<span class="ticker-item">🌡️ Treibhauseffekt</span><span class="ticker-item">🌋 Plattentektonik</span><span class="ticker-item">⚡ Energiemix</span><span class="ticker-item">🏗️ Raumplanung</span><span class="ticker-item">🌍 Planetary Boundaries</span><span class="ticker-item">🤝 Klimakonferenz</span><span class="ticker-item">📊 Bevölkerungsdynamik</span><span class="ticker-item">💧 Wasserkreislauf</span><span class="ticker-item">🏙️ Urbanisierung</span><span class="ticker-item">♻️ Nachhaltigkeit</span><span class="ticker-item">🗺️ Geomedien</span><span class="ticker-item">🇦🇹 Lehrplan 2023</span>
<span class="ticker-item">🌡️ Treibhauseffekt</span><span class="ticker-item">🌋 Plattentektonik</span><span class="ticker-item">⚡ Energiemix</span><span class="ticker-item">🏗️ Raumplanung</span><span class="ticker-item">🌍 Planetary Boundaries</span><span class="ticker-item">🤝 Klimakonferenz</span><span class="ticker-item">📊 Bevölkerungsdynamik</span><span class="ticker-item">💧 Wasserkreislauf</span><span class="ticker-item">🏙️ Urbanisierung</span><span class="ticker-item">♻️ Nachhaltigkeit</span><span class="ticker-item">🗺️ Geomedien</span><span class="ticker-item">🇦🇹 Lehrplan 2023</span>
</div></div>
<!-- FLOATING EMOJIS -->
<div class="emoji-layer" id="emojiLayer"></div>
<!-- STATS -->
<section class="sec" style="padding-top:3rem;padding-bottom:2rem">
<div class="w stats reveal">
<div class="stats-grid">
<div class="stat"><div class="stat-n" style="color:var(--fjord)">24</div><div class="stat-l">Simulationen</div></div>
<div class="stat"><div class="stat-n" style="color:var(--moss)">4</div><div class="stat-l">Schulstufen</div></div>
<div class="stat"><div class="stat-n" style="color:var(--sand)">~25'</div><div class="stat-l">pro Einsatz</div></div>
<div class="stat"><div class="stat-n" style="color:var(--coral)">330</div><div class="stat-l">Forschungsquellen</div></div>
</div>
</div>
</section>
<!-- SIMULATIONS -->
<section class="sec" id="sims" style="padding-top:2rem">
<div class="w">
<div class="reveal">
<span class="label">Simulationen</span>
<h2 class="sec-title">Lernziele, die man<br>erleben kann.</h2>
<p class="sec-sub">Jedes Modul: lehrplankonform, evidenzbasiert, 2030 Minuten.</p>
</div>
<div class="sim-grid reveal">
<a href="game.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
<div class="sim-card-body">
<h3>🌡️ Klimawächter (2D)</h3>
<p>Inselstaat bis 2100 durch die Klimakrise führen. 75 Jahre, echte Klimaphysik.</p>
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 20 min</span></div>
</div>
</a>
<a href="game-3d.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
<div class="sim-card-body">
<h3>🌊 Klimawächter (3D) <span style="background:linear-gradient(135deg,#4a7c8a,#6aa8b8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
<p>Dieselbe Insel — jetzt in echtem 3D. Steigender Meeresspiegel wird sichtbar.</p>
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 20 min</span></div>
</div>
</a>
<a href="erdbeben.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-tectonic.png" alt=""></div>
<div class="sim-card-body">
<h3>🌋 Stadtplaner*in in Erdbebenregion</h3>
<p>50 Jahre Stadt in Erdbebenzone bauen. Naturgefahr ≠ Naturkatastrophe — Bauqualität entscheidet.</p>
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
</div>
</a>
<a href="energiemix.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-energy.png" alt=""></div>
<div class="sim-card-body">
<h3>⚡ Energiewende-Planer*in</h3>
<p>Bis 2050 deine Region auf 80 % Erneuerbare bringen — ohne Blackout, ohne Pleite.</p>
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
</div>
</a>
<a href="lieferketten.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-globe.png" alt=""></div>
<div class="sim-card-body">
<h3>👕 Lieferketten-Planer*in <span style="background:linear-gradient(135deg,#4a7c8a,#6aa8b8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
<p>Was kostet dein T-Shirt wirklich? Bestelle aus aller Welt — und finde die Balance aus Preis, CO₂ und fairen Bedingungen.</p>
<div class="sim-card-tags"><span class="sim-tag st2">2. Klasse</span><span class="sim-tag st-time">⏱ 15 min</span></div>
</div>
</a>
<a href="regenwald.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img"><img src="assets/img/card-climate.png" alt=""></div>
<div class="sim-card-body">
<h3>🌴 Forscher*in im Regenwald <span style="background:linear-gradient(135deg,#5a8a5e,#7ab88a);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
<p>Fahre mit dem Boot den Fluss hoch. Entdecke 15 Tiere, Pflanzen und Menschen — vom Delta bis zum Nebelwald.</p>
<div class="sim-card-tags"><span class="sim-tag st1">1. Klasse</span><span class="sim-tag st-time">⏱ 10 min</span></div>
</div>
</a>
<a href="fluss.html" class="sim-card" style="text-decoration:none;color:inherit">
<div class="sim-card-img" style="background:linear-gradient(135deg,#87CEEB,#5a8a5e);display:flex;align-items:center;justify-content:center;"><span style="font-size:4rem">🏞️</span></div>
<div class="sim-card-body">
<h3>🏞️ Flussmanagement <span style="background:linear-gradient(135deg,#3a7ca5,#5ab8d8);color:#fff;font-size:.58rem;padding:2px 6px;border-radius:4px;font-weight:800;letter-spacing:.04em;vertical-align:middle">NEU</span></h3>
<p>Steuere ein Flusssystem: Deiche, Renaturierung, Bewässerung. Finde die Balance zwischen Schutz, Ökologie und Wirtschaft.</p>
<div class="sim-card-tags"><span class="sim-tag st1">1.2. Klasse</span><span class="sim-tag st-time">⏱ 1035 min</span><span class="sim-tag" style="background:var(--fjord-l);color:var(--fjord)">5 Levels</span></div>
</div>
</a>
<div class="sim-card">
<div class="sim-card-img"><img src="assets/img/card-city.png" alt=""></div>
<div class="sim-card-body">
<h3>🏗️ Raumplanung — Gemeinde</h3>
<p>Wohngebiete, Gewerbe und Parks platzieren. Nutzungskonflikte entstehen von selbst.</p>
<div class="sim-card-tags"><span class="sim-tag st3">3. Klasse</span><span class="sim-tag st-time">⏱ 45 min</span></div>
</div>
</div>
<div class="sim-card">
<div class="sim-card-img"><img src="assets/img/card-population.png" alt=""></div>
<div class="sim-card-body">
<h3>📊 Bevölkerungsdynamik</h3>
<p>Geburtenrate, Sterberate, Migration — Bevölkerungspyramiden über 100 Jahre.</p>
<div class="sim-card-tags"><span class="sim-tag st4">4. Klasse</span><span class="sim-tag st-time">⏱ 25 min</span></div>
</div>
</div>
<div class="sim-card">
<div class="sim-card-img"><img src="assets/img/card-globe.png" alt=""></div>
<div class="sim-card-body">
<h3>🌍 Planetary Boundaries</h3>
<p>Neun planetare Grenzen in Echtzeit. Überschreitest du die Kipppunkte?</p>
<div class="sim-card-tags"><span class="sim-tag st4">4. Klasse</span><span class="sim-tag st-time">⏱ 30 min</span></div>
</div>
</div>
</div>
<p style="text-align:center;margin-top:1.5rem;color:var(--text3);font-size:.82rem" class="reveal">+ 18 weitere Module für alle Kompetenzbereiche</p>
</div>
</section>
<!-- FEATURES -->
<section class="sec feat-sec" id="features">
<div class="w">
<div class="reveal">
<span class="label">Features</span>
<h2 class="sec-title">Für Schüler*innen<br>und Lehrkräfte.</h2>
</div>
<div class="feat-grid reveal">
<div class="feat"><div class="feat-ico">📊</div><h3>Dashboard</h3><p>Echtzeit-Überblick, Ergebnisvergleich, Fehlkonzept-Erkennung, PDF-Export.</p></div>
<div class="feat"><div class="feat-ico"></div><h3>Inklusion</h3><p>Nachteilsausgleich, vereinfachte Sprache, Vorlesefunktion, flexible Zeiten.</p></div>
<div class="feat"><div class="feat-ico">🗳️</div><h3>Live-Abstimmung</h3><p>Polls starten — Ergebnis fließt in die Simulation ein.</p></div>
<div class="feat"><div class="feat-ico">🎯</div><h3>Lehrplan 2023</h3><p>Kompetenzziele und Basiskonzepte referenziert. DACH-kompatibel.</p></div>
<div class="feat"><div class="feat-ico">⚙️</div><h3>Steuerbar</h3><p>Module freischalten, Schwierigkeit und Zeitlimits pro Schüler*in anpassen.</p></div>
<div class="feat"><div class="feat-ico">📈</div><h3>Assessment</h3><p>Predict-Observe-Explain eingebaut. Prozess- und Ergebnisdaten.</p></div>
</div>
</div>
</section>
<!-- KLASSEN -->
<section class="sec" id="klassen">
<div class="w">
<div class="reveal">
<span class="label">Vier Jahre</span>
<h2 class="sec-title">Ein Lehrplan.<br>24 Simulationen.</h2>
<p class="sec-sub">Spiralcurricular — jedes Jahr hat eigene Themen und Module.</p>
</div>
<div class="kl-grid reveal">
<div class="kl k1"><h3>1. Klasse</h3><div class="ksub">Leben und Wirtschaften</div><div class="kdet">8 Simulationen · 2 WoStd.<br>Klima · Naturgefahren · Geomedien</div></div>
<div class="kl k2"><h3>2. Klasse</h3><div class="ksub">Nachhaltigkeit</div><div class="kdet">4 Simulationen · 1 WoStd.<br>Energie · Wasser · Lieferketten</div></div>
<div class="kl k3"><h3>3. Klasse</h3><div class="ksub">Österreich</div><div class="kdet">6 Simulationen · 2 WoStd.<br>Standort · Raumplanung · Wirtschaft</div></div>
<div class="kl k4"><h3>4. Klasse</h3><div class="ksub">Globalisierte Welt</div><div class="kdet">6 Simulationen · 2 WoStd.<br>Boundaries · Klima · EU</div></div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta reveal">
<h2>Bereit, Geografie<br>zu <em>erleben</em>?</h2>
<p>Evidenzbasiert · Lehrplan-kompatibel · Inklusiv</p>
<div style="display:flex;gap:.6rem;justify-content:center">
<a href="#" class="btn btn-p" style="padding:.7rem 2rem;font-size:.9rem">🚀 Jetzt starten</a>
<a href="../Konzept/lehrplan-simulation-mapping.html" class="btn btn-o">Lehrplan-Mapping</a>
</div>
</section>
<footer>
<p>GeoGraSim — Schroffenegger & Mößlang, 2026 · 330 Quellen · <a href="../claude-research-sim/standalone.html">Forschung</a> · <a href="../claude-research/standalone.html">Didaktik</a></p>
</footer>
<script>
// === NAV SCROLL ===
const nav = document.getElementById('nav');
window.addEventListener('scroll', () => nav.classList.toggle('scrolled', scrollY > 50));
// === SCROLL REVEAL ===
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target); } });
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(el => obs.observe(el));
// === FLOATING EMOJIS ===
const emojis = ['🌍','🌎','🌏','🏔️','🌋','🌊','🌿','☀️','⛈️','🔥','💧','🌱','🏙️','🗺️','📊','🧭','⚡','♻️','🌡️','🤝','🏗️','📈','💨','🐟','⛵','🦅'];
const emojiLayer = document.getElementById('emojiLayer');
for (let i = 0; i < 30; i++) {
const el = document.createElement('span');
el.className = 'emoji-float';
el.textContent = emojis[Math.floor(Math.random() * emojis.length)];
el.style.left = Math.random() * 100 + '%';
el.style.fontSize = (1 + Math.random() * 2) + 'rem';
el.style.animationDuration = (18 + Math.random() * 30) + 's';
el.style.animationDelay = (-Math.random() * 35) + 's';
emojiLayer.appendChild(el);
}
// === SCENIC CANVAS ===
const C = document.getElementById('sc'), X = C.getContext('2d');
let W, H, t = 0;
function resize() {
const dpr = devicePixelRatio || 1;
W = C.parentElement.offsetWidth; H = C.parentElement.offsetHeight;
C.width = W * dpr; C.height = H * dpr; X.scale(dpr, dpr);
}
resize(); addEventListener('resize', resize);
// Clouds
const cls = Array.from({length:7}, () => ({
x: Math.random()*W*1.5, y: H*(.04+Math.random()*.18),
w: 40+Math.random()*100, sp: .06+Math.random()*.14, op: .2+Math.random()*.4
}));
// Birds
const bds = Array.from({length:5}, () => ({
x: Math.random()*W, y: H*(.06+Math.random()*.24),
sp: .25+Math.random()*.35, sz: 3+Math.random()*5,
wp: Math.random()*6.28, ws: 2.5+Math.random()*2, op: .15+Math.random()*.2
}));
// Boats
const boats = [
{ x: W*.15, sp: .22, ph: 0, sc: .9, op: .65 },
{ x: W*.6, sp: .12, ph: 2, sc: .45, op: .3 },
{ x: W*.85, sp: .08, ph: 4, sc: .3, op: .2 }
];
// Fish jumping
let fishTimer = 0, fishX = 0, fishY = 0, fishPhase = -1;
function frame() {
t += .016; X.clearRect(0,0,W,H);
const wl = H * .56;
// Sky
const sk = X.createLinearGradient(0,0,0,wl);
sk.addColorStop(0,'#e8ebe4'); sk.addColorStop(.5,'#dde3d8'); sk.addColorStop(1,'#ced8ca');
X.fillStyle = sk; X.fillRect(0,0,W,wl);
// Sun
const sg = X.createRadialGradient(W*.78,H*.1,0,W*.78,H*.1,H*.3);
sg.addColorStop(0,'rgba(255,235,200,.3)'); sg.addColorStop(1,'rgba(255,235,200,0)');
X.fillStyle = sg; X.fillRect(0,0,W,wl);
// Mountains (3 layers)
[{c:'#c0caba',p:6,r:.05,h:.3},{c:'#a8b5a0',p:9,r:.08,h:.4},{c:'#8a9a82',p:12,r:.11,h:.5}].forEach(m => {
X.beginPath(); X.moveTo(0,wl);
const sw = W/m.p;
for(let i=0;i<=m.p;i++){
const px=i*sw, py=wl-wl*m.h*(.4+Math.sin(i*1.7+.3)*.3+Math.cos(i*.9)*m.r);
i===0?X.lineTo(px,py):X.quadraticCurveTo(px-sw*.4,py-wl*m.r*.2,px,py);
}
X.lineTo(W,wl); X.closePath(); X.fillStyle=m.c; X.fill();
// Snow caps on nearest layer
if(m.h===.5){
X.save(); X.globalAlpha=.35;
for(let i=1;i<m.p;i++){
const px=i*sw, peak=wl-wl*m.h*(.4+Math.sin(i*1.7+.3)*.3+Math.cos(i*.9)*m.r);
if(peak<wl*.35){ X.beginPath(); X.ellipse(px,peak+5,12,8,0,0,Math.PI*2); X.fillStyle='#e8e8e0'; X.fill(); }
}
X.restore();
}
});
// Clouds
cls.forEach(c => {
c.x += c.sp; if(c.x>W+c.w*2){c.x=-c.w*2; c.y=H*(.04+Math.random()*.18);}
X.globalAlpha=c.op; X.fillStyle='#fff';
const s=c.w/80;
X.beginPath(); X.ellipse(c.x,c.y,32*s,13*s,0,0,Math.PI*2); X.fill();
X.beginPath(); X.ellipse(c.x-18*s,c.y+2*s,22*s,10*s,0,0,Math.PI*2); X.fill();
X.beginPath(); X.ellipse(c.x+20*s,c.y+3*s,24*s,9*s,0,0,Math.PI*2); X.fill();
X.globalAlpha=1;
});
// Birds
bds.forEach(b => {
b.x+=b.sp; b.wp+=b.ws*.016; b.y+=Math.sin(t*.4+b.wp)*.12;
if(b.x>W+60){b.x=-60; b.y=H*(.06+Math.random()*.24);}
X.globalAlpha=b.op; X.strokeStyle='#5a5a5a'; X.lineWidth=1.2; X.lineCap='round';
const w=Math.sin(b.wp)*.45;
X.beginPath(); X.moveTo(b.x-b.sz,b.y-w*b.sz);
X.quadraticCurveTo(b.x,b.y+1.5,b.x+b.sz,b.y-w*b.sz); X.stroke();
X.globalAlpha=1;
});
// Water
const wg = X.createLinearGradient(0,wl,0,H);
wg.addColorStop(0,'#9ab5b8'); wg.addColorStop(.4,'#88a8ab'); wg.addColorStop(1,'#7a9ea2');
X.fillStyle=wg; X.fillRect(0,wl,W,H-wl);
// Waves
for(let i=0;i<5;i++){
X.beginPath(); X.moveTo(0,wl);
for(let x=0;x<=W;x+=3) X.lineTo(x, wl+Math.sin(x*.005+t*(.25+i*.1)+i*2)*(1.2+i*1.1)+i*.3);
X.lineTo(W,H); X.lineTo(0,H); X.closePath();
X.fillStyle=`rgba(255,255,255,${.025+i*.008})`; X.fill();
}
// Water sparkles
for(let i=0;i<12;i++){
const rx=(W*.1+i*W*.08+Math.sin(t*.15+i)*15)%W;
const ry=wl+12+i*6+Math.sin(t*.4+i*2.5)*2.5;
X.globalAlpha=Math.sin(t*1.8+i*1.3)*.5+.5; X.globalAlpha*=.1;
X.fillStyle='#fff'; X.fillRect(rx,ry,8+Math.random()*10,1.2);
}
X.globalAlpha=1;
// Boats
boats.forEach(b => {
b.x+=b.sp; b.ph+=.015; if(b.x>W+120)b.x=-120;
const by=wl-5*b.sc+Math.sin(b.ph)*2.5*b.sc;
const tilt=Math.sin(b.ph+.5)*.03;
X.save(); X.translate(b.x,by); X.rotate(tilt); X.globalAlpha=b.op;
const s=b.sc;
// Hull
X.fillStyle='#b06a5a'; X.beginPath();
X.moveTo(-22*s,0); X.lineTo(-17*s,9*s); X.lineTo(17*s,9*s); X.lineTo(22*s,0); X.closePath(); X.fill();
// Water line
X.fillStyle='#8a4a3a'; X.fillRect(-17*s,7*s,34*s,2*s);
// Mast
X.strokeStyle='#4a4a4a'; X.lineWidth=1.5*s;
X.beginPath(); X.moveTo(0,0); X.lineTo(0,-28*s); X.stroke();
// Großsegel (achtern, in Fahrtrichtung — größer)
X.fillStyle='#f0ece5'; X.beginPath();
X.moveTo(1,-26*s); X.lineTo(16*s,-5*s); X.lineTo(1,-2*s); X.closePath(); X.fill();
// Vorsegel / Fock (vorn, kleiner)
X.fillStyle='#e8e3d8'; X.beginPath();
X.moveTo(-1,-22*s); X.lineTo(-11*s,-3*s); X.lineTo(-1,-1*s); X.closePath(); X.fill();
// Flag
X.fillStyle='#c07a6b'; X.beginPath();
X.moveTo(0,-28*s); X.lineTo(8*s,-25*s); X.lineTo(0,-22*s); X.closePath(); X.fill();
// Wake
X.globalAlpha=b.op*.4; X.strokeStyle='rgba(255,255,255,.3)'; X.lineWidth=1;
X.beginPath(); X.moveTo(-22*s,4*s);
X.quadraticCurveTo(-35*s,6*s,-50*s,3*s); X.stroke();
X.restore(); X.globalAlpha=1;
});
// Fish jumping
fishTimer-=.016;
if(fishTimer<=0){ fishTimer=4+Math.random()*6; fishX=W*.2+Math.random()*W*.6; fishY=wl+5; fishPhase=0; }
if(fishPhase>=0 && fishPhase<1){
fishPhase+=.02;
const fy=fishY-Math.sin(fishPhase*Math.PI)*30;
const rot=-Math.PI*.3+fishPhase*Math.PI*.6;
X.save(); X.translate(fishX,fy); X.rotate(rot); X.globalAlpha=.4;
X.fillStyle='#6a8a8e';
X.beginPath(); X.ellipse(0,0,6,2.5,0,0,Math.PI*2); X.fill();
// Tail
X.beginPath(); X.moveTo(-6,0); X.lineTo(-10,-3); X.lineTo(-10,3); X.closePath(); X.fill();
X.restore(); X.globalAlpha=1;
// Splash
if(fishPhase>.45 && fishPhase<.55){
X.globalAlpha=.15; X.fillStyle='#fff';
for(let i=0;i<5;i++){
X.beginPath(); X.arc(fishX+(Math.random()-.5)*15,fishY-2,1+Math.random()*2,0,Math.PI*2); X.fill();
}
X.globalAlpha=1;
}
}
// Bottom fade
const fd = X.createLinearGradient(0,H*.78,0,H);
fd.addColorStop(0,'rgba(247,246,243,0)'); fd.addColorStop(1,'rgba(247,246,243,1)');
X.fillStyle=fd; X.fillRect(0,H*.78,W,H*.22);
requestAnimationFrame(frame);
}
frame();
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* GeoGraSim — Front Controller
* Alle Requests laufen ueber diese Datei (via .htaccess Rewrite).
*/
require_once __DIR__ . '/php/config/app.php';
require_once __DIR__ . '/php/lib/Database.php';
require_once __DIR__ . '/php/lib/Session.php';
require_once __DIR__ . '/php/lib/Response.php';
require_once __DIR__ . '/php/templates/_scripts.php';
Session::start();
// Request-Pfad parsen
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$basePath = BASE_PATH;
$path = parse_url($requestUri, PHP_URL_PATH);
// Base-Path entfernen
if (strpos($path, $basePath) === 0) {
$path = substr($path, strlen($basePath));
}
$path = trim($path, '/');
if ($path === '') $path = 'index';
// API-Routen
if (strpos($path, 'api/') === 0) {
$apiFile = preg_replace('/[^a-z0-9\-]/', '', substr($path, 4));
$apiPath = __DIR__ . '/php/api/' . $apiFile . '.php';
if (file_exists($apiPath)) {
require $apiPath;
exit;
}
Response::error('API-Endpunkt nicht gefunden', 404);
}
// Seiten-Routen
$page = preg_replace('/[^a-z0-9\-]/', '', $path);
$pageFile = __DIR__ . '/pages/' . $page . '.php';
if (file_exists($pageFile)) {
require $pageFile;
exit;
}
// Fallback: Original HTML-Dateien (Uebergangsphase)
$htmlFile = __DIR__ . '/' . $page . '.html';
if (file_exists($htmlFile)) {
readfile($htmlFile);
exit;
}
// 404
http_response_code(404);
echo '<!DOCTYPE html><html><body><h1>404 — Seite nicht gefunden</h1><p><a href="' . BASE_PATH . '/">Zur&uuml;ck zur Startseite</a></p></body></html>';
+545
View File
@@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Lieferketten-Planer*in</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
--coral: #c07a6b; --coral-l: #f2ddd8;
--danger: #b04a3a;
--glass-bg: rgba(255,255,255,.85);
--glass-bg2: rgba(255,255,255,.92);
--glass-bord: rgba(255,255,255,.55);
--glass-shadow: 0 8px 32px rgba(20,40,60,.18);
--topbar-h: 2.8rem;
--side-w: 220px;
--pad: .65rem;
}
html, body { height:100%; overflow:hidden; }
body { font-family:'Inter',system-ui,sans-serif; background:#dceaf0; color:var(--text); line-height:1.5; }
#canvas-wrap { position:fixed; inset:0; z-index:0; }
/* === FLOATING PANELS === */
.floating {
position:fixed; z-index:5;
backdrop-filter:blur(14px) saturate(140%); -webkit-backdrop-filter:blur(14px) saturate(140%);
background:var(--glass-bg); border:1px solid var(--glass-bord);
box-shadow:var(--glass-shadow); border-radius:14px;
}
/* === TOPBAR === */
.topbar {
top:0; left:0; right:0;
padding:.35rem .7rem;
display:flex; justify-content:space-between; align-items:center; gap:.8rem;
border-radius:0; border-bottom:1px solid rgba(0,0,0,.08);
}
.topbar .brand {
display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:.92rem;
color:var(--fjord); text-decoration:none; white-space:nowrap;
}
.topbar .brand img { width:24px; height:24px; border-radius:6px; }
.topbar-controls { display:flex; gap:.5rem; align-items:center; }
.icon-btn {
width:32px; height:32px; border-radius:8px;
border:1px solid rgba(0,0,0,.08); background:rgba(255,255,255,.55);
color:var(--text2); font-size:.95rem; cursor:pointer;
display:flex; align-items:center; justify-content:center; padding:0;
text-decoration:none; transition:all .15s;
}
.icon-btn:hover { background:var(--fjord-l); border-color:var(--fjord); color:var(--fjord); }
.ctrl-group {
display:flex; gap:.2rem; padding:.2rem;
background:rgba(255,255,255,.45); border-radius:9px;
border:1px solid rgba(0,0,0,.06);
}
.ctrl-group .icon-btn {
border:none; background:transparent;
width:30px; height:30px; font-size:.8rem; font-weight:700;
}
.ctrl-group .icon-btn:hover { background:rgba(0,0,0,.06); }
.ctrl-group .icon-btn.active { background:var(--fjord); color:#fff; }
.topbar-sep { width:1px; height:24px; background:rgba(0,0,0,.08); }
/* === STATUS-PANEL (LINKS) === */
.panel-h {
font-size:.58rem; font-weight:800; color:var(--fjord);
text-transform:uppercase; letter-spacing:.07em; margin-bottom:.4rem;
}
.panel-h-sub { margin-top:.9rem; padding-top:.6rem; border-top:1px solid rgba(0,0,0,.08); }
.panel-status {
top: calc(var(--topbar-h) + var(--pad));
left: var(--pad);
width: var(--side-w);
padding: .6rem .75rem;
bottom: var(--pad);
overflow-y: auto;
}
.resource {
display:flex; align-items:flex-start; gap:.5rem; padding:.3rem .1rem;
border-bottom:1px solid rgba(0,0,0,.06);
}
.resource:last-child { border-bottom:none; }
.resource-icon { font-size:1.15rem; width:22px; text-align:center; padding-top:.1rem; }
.resource-info { flex:1; min-width:0; }
.resource-name { font-size:.58rem; color:var(--text3); font-weight:500; line-height:1.1; }
.resource-val { font-size:.85rem; font-weight:800; color:var(--text); line-height:1.2; }
/* === GOALS === */
.goal { padding:.3rem 0; border-bottom:1px solid rgba(0,0,0,.06); }
.goal:last-child { border:0; }
.goal-row { display:flex; align-items:center; gap:.35rem; font-size:.7rem; }
.goal-check {
width:12px; height:12px; border:1.5px solid var(--text3);
border-radius:3px; display:flex; align-items:center; justify-content:center;
font-size:.52rem; flex-shrink:0;
}
.goal-check.done { background:var(--moss); border-color:var(--moss); color:#fff; }
.goal-bar { height:3px; background:rgba(0,0,0,.08); border-radius:2px; margin-top:.2rem; overflow:hidden; }
.goal-bar-fill { height:100%; background:var(--moss); transition:width .3s; }
/* === BESTELL-FORMULAR (RECHTS) === */
.panel-order {
top: calc(var(--topbar-h) + var(--pad));
right: var(--pad);
bottom: var(--pad);
width: 300px;
padding: .85rem 1rem 1rem;
overflow-y: auto;
}
.order-section { margin-bottom: 1rem; }
.order-section h4 {
font-size: .65rem; font-weight: 800; color: var(--fjord);
text-transform: uppercase; letter-spacing: .07em;
margin-bottom: .35rem;
}
.order-options { display: flex; flex-direction: column; gap: .25rem; }
.order-option {
display: flex; align-items: center; gap: .4rem;
padding: .35rem .5rem; border-radius: 8px;
background: rgba(255,255,255,.55);
border: 1.5px solid rgba(0,0,0,.06);
cursor: pointer; transition: all .12s;
font-family: inherit; text-align: left;
}
.order-option:hover { border-color: var(--fjord); background: var(--fjord-l); }
.order-option.selected { border-color: var(--moss); background: var(--moss-l); }
.oo-flag { font-size: .9rem; flex-shrink: 0; }
.oo-info { flex: 1; min-width: 0; }
.oo-name { font-size: .68rem; font-weight: 700; line-height: 1.15; }
.oo-stats {
font-size: .55rem; color: var(--text3); line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.oo-stats .oo-stat { display: inline-block; margin-right: .35rem; }
.oo-stat-good { color: var(--moss); }
.oo-stat-bad { color: var(--danger); }
.transport-row { display: flex; gap: .25rem; }
.transport-btn {
flex: 1; padding: .4rem .25rem; border-radius: 8px;
background: rgba(255,255,255,.55); border: 1.5px solid rgba(0,0,0,.06);
cursor: pointer; font-family: inherit;
display: flex; flex-direction: column; align-items: center; gap: .1rem;
transition: all .12s;
}
.transport-btn:hover { border-color: var(--fjord); background: var(--fjord-l); }
.transport-btn.selected { border-color: var(--moss); background: var(--moss-l); }
.transport-btn .tb-emoji { font-size: 1.1rem; }
.transport-btn .tb-name { font-size: .55rem; font-weight: 700; }
.order-summary {
margin-top: .8rem; padding: .65rem .75rem;
background: rgba(0,0,0,.04); border-radius: 9px;
font-size: .68rem; line-height: 1.5;
}
.order-summary .os-row {
display: flex; justify-content: space-between;
font-variant-numeric: tabular-nums;
}
.order-summary .os-row strong { font-weight: 800; }
.os-good { color: var(--moss); }
.os-bad { color: var(--danger); }
.order-btn {
margin-top: .7rem; width: 100%;
padding: .65rem; border: none; border-radius: 9px;
background: var(--fjord); color: #fff;
font-weight: 800; font-size: .85rem; cursor: pointer;
font-family: inherit; transition: all .15s;
}
.order-btn:hover { background: var(--fjord-d); }
.order-btn:disabled { background: #c0c0c0; cursor: not-allowed; }
/* === EVENTS-STRIP (unten links neben dem Status) === */
.panel-events-strip {
position: fixed;
left: calc(var(--side-w) + var(--pad) * 2);
bottom: var(--pad);
width: 280px;
max-height: 90px;
padding: .35rem .55rem .45rem;
overflow: hidden;
cursor: pointer;
transition: max-height .38s cubic-bezier(.22,1,.36,1);
z-index: 12;
display: flex; flex-direction: column; gap: .25rem;
}
.panel-events-strip.open {
max-height: calc(100vh - var(--topbar-h) - 4 * var(--pad));
box-shadow: 0 18px 50px rgba(20,40,60,.28);
cursor: zoom-out;
}
.events-strip-header {
display: flex; justify-content: space-between; align-items: center;
font-size: .58rem; font-weight: 800; color: var(--fjord);
text-transform: uppercase; letter-spacing: .07em;
flex-shrink: 0;
}
.esh-toggle { transition: transform .35s ease-out; font-size: .65rem; color: var(--text3); }
.panel-events-strip.open .esh-toggle { transform: rotate(180deg); }
.events-strip-list {
overflow-y: auto;
flex: 1; min-height: 0;
}
.panel-events-strip:not(.open) .events-strip-list .event:not(:first-child) {
display: none;
}
.panel-events-strip:not(.open) .events-strip-list .event {
margin-bottom: 0;
}
.event {
padding:.3rem .45rem; border-radius:7px; margin-bottom:.25rem;
font-size:.65rem; line-height:1.3;
display:flex; gap:.3rem; align-items:flex-start;
}
.event-text { flex:1; }
.event-info { background:rgba(0,0,0,.05); color:var(--text2); }
.event-success { background:var(--moss-l); color:var(--moss); }
.event-warning { background:var(--sand-l); color:var(--sand); }
.event-danger { background:var(--coral-l); color:var(--danger); }
/* === TUTORIAL === */
.tutorial-overlay {
position:fixed; inset:0; background:rgba(15,30,40,.85);
backdrop-filter:blur(8px); display:flex; align-items:center;
justify-content:center; z-index:50; padding:1.5rem;
}
.tutorial-card {
background:#fff; border-radius:18px; padding:1.6rem 1.9rem;
max-width:520px; box-shadow:0 30px 80px rgba(0,0,0,.4);
}
.tutorial-card h3 { font-size:1.25rem; font-weight:800; margin-bottom:.6rem; color:var(--fjord); }
.tutorial-card p { font-size:.9rem; color:var(--text2); white-space:pre-line; margin-bottom:1rem; }
.progress-dots { display:flex; gap:.3rem; justify-content:center; margin:.8rem 0; }
.dot { width:8px; height:8px; border-radius:50%; background:#dadad0; }
.dot.active { background:var(--fjord); }
.tut-btn {
width:100%; padding:.75rem; background:var(--fjord); color:#fff;
border:none; border-radius:10px; font-weight:700; font-size:.95rem; cursor:pointer;
}
.tut-btn:hover { background:var(--fjord-d); }
/* === END SCREEN === */
.end-screen {
position:fixed; inset:0; background:rgba(15,30,40,.9);
backdrop-filter:blur(10px); display:flex; align-items:center;
justify-content:center; z-index:100; padding:2rem;
}
.end-card { background:#fff; border-radius:20px; padding:2rem 2.5rem; max-width:500px; text-align:center; box-shadow:0 30px 80px rgba(0,0,0,.4); }
.end-icon { font-size:4rem; margin-bottom:.5rem; }
.end-card h2 { font-size:1.8rem; font-weight:900; letter-spacing:-.02em; margin-bottom:.3rem; }
.end-card .end-summary { color:var(--text2); margin-bottom:1rem; }
.end-stats { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; margin:1rem 0; }
.end-stat { background:#f7f6f3; border-radius:10px; padding:.8rem; }
.end-stat-val { font-size:1.4rem; font-weight:800; color:var(--fjord); }
.end-stat-lbl { font-size:.7rem; color:var(--text3); text-transform:uppercase; }
.end-goals { display:flex; flex-direction:column; gap:.3rem; margin:.8rem 0 1rem; text-align:left; }
.end-goal { display:flex; align-items:center; gap:.55rem; font-size:.82rem; padding:.35rem .55rem; border-radius:7px; background:#f7f6f3; }
.end-goal.done { color:var(--moss); background:var(--moss-l); }
.end-goal.fail { color:var(--danger); background:var(--coral-l); }
.end-goal-icon { font-weight:900; font-size:.95rem; width:1rem; text-align:center; }
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<!-- TOP BAR -->
<nav class="floating topbar">
<a href="index.html" class="brand">
<img src="assets/img/logo.png" alt="">
Lieferketten-Planer*in
</a>
<div class="topbar-controls">
<div class="ctrl-group" title="Simulationsgeschwindigkeit">
<button class="icon-btn" data-speed="0" title="Pause"></button>
<button class="icon-btn active" data-speed="1" title="Normal (×1)">×1</button>
<button class="icon-btn" data-speed="2" title="Schnell (×2)">×2</button>
<button class="icon-btn" data-speed="4" title="Sehr schnell (×4)">×4</button>
</div>
<div class="topbar-sep"></div>
<button class="icon-btn" id="btn-save" title="Stand speichern">💾</button>
<button class="icon-btn" id="btn-load" title="Stand laden">📂</button>
<button class="icon-btn" id="btn-finish" title="Simulation beenden"></button>
<button class="icon-btn" id="btn-reset" title="Zurücksetzen">🔄</button>
<a class="icon-btn" href="index.html" title="Zurück zur Übersicht">🏠</a>
</div>
</nav>
<!-- LINKS: Status + Ziele -->
<aside class="floating panel-status">
<div class="panel-h">📊 Status</div>
<div id="resources"></div>
<div class="panel-h panel-h-sub">🎯 Ziele</div>
<div id="status-goals"></div>
</aside>
<!-- LINKS DANEBEN: Ereignisse-Strip -->
<aside class="floating panel-events-strip" id="events-strip">
<div class="events-strip-header">
<span>📰 Ereignisse</span>
<span class="esh-toggle"></span>
</div>
<div id="events" class="events-strip-list"></div>
</aside>
<!-- RECHTS: Bestell-Formular -->
<aside class="floating panel-order">
<div class="order-section">
<h4>🌾 Baumwolle</h4>
<div class="order-options" id="cotton-options"></div>
</div>
<div class="order-section">
<h4>🚚 Transport zur Näherei</h4>
<div class="transport-row" id="transport1-row"></div>
</div>
<div class="order-section">
<h4>🏭 Näherei</h4>
<div class="order-options" id="factory-options"></div>
</div>
<div class="order-section">
<h4>🚚 Transport nach Wien</h4>
<div class="transport-row" id="transport2-row"></div>
</div>
<div class="order-summary" id="order-summary"></div>
<button class="order-btn" id="order-btn" disabled>Bestellen</button>
</aside>
<!-- TUTORIAL -->
<div class="tutorial-overlay" id="tutorial">
<div class="tutorial-card">
<h3 id="tut-title"></h3>
<p id="tut-text"></p>
<div class="progress-dots" id="tut-dots"></div>
<button class="tut-btn" id="tut-next">Weiter →</button>
</div>
</div>
<div id="end-screen-host"></div>
<div id="citizen-event-host"></div>
<div id="info-overlay-host"></div>
<script type="module">
import {
LieferkettenGame, COTTON_SOURCES, FACTORIES, TRANSPORT_MODES, DEFAULT_CONFIG,
} from './src/sims/sim-10-lieferketten/game.ts'
import { LieferkettenRenderer } from './src/sims/sim-10-lieferketten/renderer.ts'
import { GameUI } from './src/ui/game-ui.ts'
import { installTooltips } from './src/ui/info-overlay.ts'
installTooltips()
// === Lehrer-Konfiguration via URL-Parameter ===
// ?config=<json> erlaubt der Lehrperson, die drei Hauptparameter zu setzen.
function readConfig() {
try {
const raw = new URLSearchParams(location.search).get('config')
if (raw) return JSON.parse(decodeURIComponent(raw))
} catch {}
return {}
}
const userConfig = readConfig()
const game = new LieferkettenGame(userConfig)
// === GameUI ohne Shop (wir bauen unser eigenes Bestell-Formular) ===
// Wir geben einen leeren shopItems-Array, damit GameUI nichts rendert.
const ui = new GameUI({
game,
Renderer: LieferkettenRenderer,
shopItems: [],
onBuy: () => false,
graphs: [],
saveKey: 'lieferketten-save',
yearOffset: 2025,
})
// === Bestell-Formular bauen ===
const filterByConfig = (list, allowed) => list.filter(x => allowed.includes(x.id))
const cottonAvailable = filterByConfig(COTTON_SOURCES, game.getConfig().enabledOptions.cotton)
const factoryAvailable = filterByConfig(FACTORIES, game.getConfig().enabledOptions.factory)
const transportAvailable = TRANSPORT_MODES.filter(t => game.getConfig().enabledOptions.transport.includes(t.id))
const currentOrder = {
cottonId: null,
factoryId: null,
transport1: null,
transport2: null,
}
function ratingClass(value, goodAbove, badBelow) {
if (value >= goodAbove) return 'oo-stat-good'
if (value < badBelow) return 'oo-stat-bad'
return ''
}
function renderCottonOptions() {
const el = document.getElementById('cotton-options')
el.innerHTML = cottonAvailable.map(c => `
<button class="order-option ${currentOrder.cottonId === c.id ? 'selected' : ''}" data-id="${c.id}">
<span class="oo-flag">${c.flag}</span>
<div class="oo-info">
<div class="oo-name">${c.country}</div>
<div class="oo-stats">
<span class="oo-stat">€${c.pricePerShirt.toFixed(2)}</span>
<span class="oo-stat ${ratingClass(-c.co2PerShirt, -1.5, -2.4)}">${c.co2PerShirt.toFixed(1)} kg CO₂</span>
<span class="oo-stat ${ratingClass(c.ethicsScore, 7, 5)}">⚖️ ${c.ethicsScore}</span>
</div>
</div>
</button>
`).join('')
el.querySelectorAll('.order-option').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder.cottonId = btn.dataset.id
renderCottonOptions(); updatePreview()
})
})
}
function renderFactoryOptions() {
const el = document.getElementById('factory-options')
el.innerHTML = factoryAvailable.map(f => `
<button class="order-option ${currentOrder.factoryId === f.id ? 'selected' : ''}" data-id="${f.id}">
<span class="oo-flag">${f.flag}</span>
<div class="oo-info">
<div class="oo-name">${f.country}</div>
<div class="oo-stats">
<span class="oo-stat">€${f.pricePerShirt.toFixed(2)}</span>
<span class="oo-stat ${ratingClass(-f.co2PerShirt, -1.0, -1.5)}">${f.co2PerShirt.toFixed(1)} kg CO₂</span>
<span class="oo-stat ${ratingClass(f.ethicsScore, 7, 5)}">⚖️ ${f.ethicsScore}</span>
</div>
</div>
</button>
`).join('')
el.querySelectorAll('.order-option').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder.factoryId = btn.dataset.id
renderFactoryOptions(); updatePreview()
})
})
}
function renderTransportRow(rowId, key) {
const el = document.getElementById(rowId)
el.innerHTML = transportAvailable.map(t => `
<button class="transport-btn ${currentOrder[key] === t.id ? 'selected' : ''}" data-id="${t.id}">
<span class="tb-emoji">${t.emoji}</span>
<span class="tb-name">${t.name.split(' ')[0]}</span>
</button>
`).join('')
el.querySelectorAll('.transport-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder[key] = btn.dataset.id
renderTransportRow(rowId, key); updatePreview()
})
})
}
function updatePreview() {
// Renderer-Vorschau aktualisieren
const r = ui.getRenderer()
if (r && typeof r.setPreviewOrder === 'function') {
r.setPreviewOrder(currentOrder)
}
// Order-Summary berechnen
const summary = document.getElementById('order-summary')
const btn = document.getElementById('order-btn')
const complete = currentOrder.cottonId && currentOrder.factoryId && currentOrder.transport1 && currentOrder.transport2
if (!complete) {
summary.innerHTML = '<div style="color:var(--text3);text-align:center;font-size:.7rem">Wähle alle 4 Stationen aus.</div>'
btn.disabled = true
return
}
// Vorschau-Berechnung lokal (mirror der placeOrder-Logik)
const c = COTTON_SOURCES.find(x => x.id === currentOrder.cottonId)
const f = FACTORIES.find(x => x.id === currentOrder.factoryId)
const t1 = TRANSPORT_MODES.find(x => x.id === currentOrder.transport1)
const t2 = TRANSPORT_MODES.find(x => x.id === currentOrder.transport2)
// Sehr grobe Schätzung — wir nutzen die Distanz-Mittelwerte (nicht die Effekte)
const dist1 = 5 // ~5000 km Schätzwert für Vorschau
const dist2 = 3 // ~3000 km Schätzwert
const t1Cost = t1.costPer1000km * dist1
const t2Cost = t2.costPer1000km * dist2
const t1CO2 = t1.co2Per1000km * dist1
const t2CO2 = t2.co2Per1000km * dist2
const costPerShirt = c.pricePerShirt + f.pricePerShirt + t1Cost + t2Cost
const co2PerShirt = c.co2PerShirt + f.co2PerShirt + t1CO2 + t2CO2
const ethics = (c.ethicsScore + f.ethicsScore) / 2
const profitPerShirt = 12 - costPerShirt
const profitMio = (5000 * profitPerShirt) / 1_000_000
const profitClass = profitMio >= 0 ? 'os-good' : 'os-bad'
const co2Class = co2PerShirt < 6 ? 'os-good' : 'os-bad'
const ethicsClass = ethics >= 5 ? 'os-good' : 'os-bad'
summary.innerHTML = `
<div class="os-row"><span>Kosten/Stück</span><strong>€${costPerShirt.toFixed(2)}</strong></div>
<div class="os-row"><span>Gewinn/Charge</span><strong class="${profitClass}">${profitMio >= 0 ? '+' : ''}${profitMio.toFixed(2)} Mio €</strong></div>
<div class="os-row"><span>CO₂/Stück</span><strong class="${co2Class}">${co2PerShirt.toFixed(1)} kg</strong></div>
<div class="os-row"><span>Ø Ethik</span><strong class="${ethicsClass}">${ethics.toFixed(1)} / 10</strong></div>
`
btn.disabled = false
}
document.getElementById('order-btn').addEventListener('click', () => {
if (!currentOrder.cottonId || !currentOrder.factoryId || !currentOrder.transport1 || !currentOrder.transport2) return
game.placeOrder({ ...currentOrder })
// Sprite-Animation auf der Karte
const r = ui.getRenderer()
if (r && typeof r.animateOrder === 'function') r.animateOrder(currentOrder)
})
// Events-Strip toggle
const eventsStrip = document.getElementById('events-strip')
eventsStrip.addEventListener('click', (ev) => {
ev.stopPropagation()
eventsStrip.classList.toggle('open')
})
document.addEventListener('click', (ev) => {
if (!eventsStrip.classList.contains('open')) return
if (eventsStrip.contains(ev.target)) return
eventsStrip.classList.remove('open')
})
// Initial render
renderCottonOptions()
renderFactoryOptions()
renderTransportRow('transport1-row', 'transport1')
renderTransportRow('transport2-row', 'transport2')
updatePreview()
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "geograsim",
"version": "0.1.0",
"description": "Interaktive Simulationen für den Geografieunterricht",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"keywords": [
"geografie",
"simulation",
"bildung"
],
"author": "Thomas Schroffenegger & Dominik Mößlang",
"license": "MIT",
"devDependencies": {
"@vitest/ui": "^4.1.4",
"jsdom": "^29.0.2",
"playwright": "^1.59.1",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"vitest": "^4.1.4"
},
"dependencies": {
"@types/three": "^0.183.1",
"three": "^0.183.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Lehrkraefte-Dashboard */
$html = file_get_contents(APP_ROOT . '/dashboard.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Energiemix-Simulator */
$html = file_get_contents(APP_ROOT . '/energiemix.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Erdbeben-Simulator */
$html = file_get_contents(APP_ROOT . '/erdbeben.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Flussmanagement-Simulation */
$html = file_get_contents(APP_ROOT . '/fluss.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* GeoGraSim — Klimawächter (3D)
*/
$html = file_get_contents(APP_ROOT . '/game-3d.html');
$sessionScript = '<script>' . "\n"
. 'window.__GGS__ = ' . json_encode([
'sessionId' => Session::studentId(),
'teacherId' => Session::teacherId(),
'baseUrl' => BASE_URL,
'basePath' => BASE_PATH,
], JSON_UNESCAPED_UNICODE) . ";\n"
. '</script>';
$html = str_replace('</body>', $sessionScript . "\n</body>", $html);
echo $html;
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* GeoGraSim — Klimawächter (2D)
*/
$html = file_get_contents(APP_ROOT . '/game.html');
$sessionScript = '<script>' . "\n"
. 'window.__GGS__ = ' . json_encode([
'sessionId' => Session::studentId(),
'teacherId' => Session::teacherId(),
'baseUrl' => BASE_URL,
'basePath' => BASE_PATH,
], JSON_UNESCAPED_UNICODE) . ";\n"
. '</script>';
$html = str_replace('</body>', $sessionScript . "\n</body>", $html);
echo $html;
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* GeoGraSim — Startseite (Landing Page)
* Laedt die bestehende index.html und injiziert Session-Kontext.
*/
$html = file_get_contents(APP_ROOT . '/index.html');
// Session-Kontext vor </body> injizieren
$sessionScript = '<script>' . "\n"
. 'window.__GGS__ = ' . json_encode([
'sessionId' => Session::studentId(),
'teacherId' => Session::teacherId(),
'baseUrl' => BASE_URL,
'basePath' => BASE_PATH,
], JSON_UNESCAPED_UNICODE) . ";\n"
. '</script>';
$html = str_replace('</body>', $sessionScript . "\n</body>", $html);
echo $html;
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Lieferketten-Simulator */
$html = file_get_contents(APP_ROOT . '/lieferketten.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Regenwald-Explorer */
$html = file_get_contents(APP_ROOT . '/regenwald.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Simulationsseite (POE-Workflow) */
$html = file_get_contents(APP_ROOT . '/sim.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+5
View File
@@ -0,0 +1,5 @@
<?php
/** GeoGraSim — Stilauswahl */
$html = file_get_contents(APP_ROOT . '/stilauswahl.html');
$ctx = '<script>window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';</script>';
echo str_replace('</body>', $ctx . "\n</body>", $html);
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* API: Assessment-Daten speichern
* POST /api/assessment {simId, processLog, predictions, results, reflections, duration, completedPhases}
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
Response::error('Nur POST erlaubt', 405);
}
$sessionId = Session::requireStudent();
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || !isset($body['simId'])) {
Response::error('simId erforderlich');
}
$db = Database::get();
// class_id aus student_session holen
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
$classId = $session ? $session['class_id'] : null;
$db->execute(
'INSERT INTO assessments (session_id, sim_id, class_id, process_log, predictions, results, reflections, duration_ms, completed_phases)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$sessionId,
$body['simId'],
$classId,
json_encode($body['processLog'] ?? []),
json_encode($body['predictions'] ?? []),
json_encode($body['results'] ?? []),
json_encode($body['reflections'] ?? []),
$body['duration'] ?? 0,
json_encode($body['completedPhases'] ?? []),
]
);
Response::ok(['id' => $db->lastInsertId()]);
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* API: Lehrkraefte-Dashboard Daten
* GET /api/dashboard?class_id=X
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
Response::error('Nur GET erlaubt', 405);
}
$teacherId = Session::requireTeacher();
$db = Database::get();
$classId = (int)($_GET['class_id'] ?? 0);
// Verifizieren dass die Klasse dem Lehrer gehoert
if ($classId) {
$class = $db->fetchOne(
'SELECT * FROM classes WHERE id = ? AND teacher_id = ?',
[$classId, $teacherId]
);
if (!$class) Response::error('Klasse nicht gefunden', 404);
}
// Klassen des Lehrers
$classes = $db->fetchAll('SELECT * FROM classes WHERE teacher_id = ? ORDER BY created_at DESC', [$teacherId]);
if (!$classId && !empty($classes)) {
$classId = $classes[0]['id'];
}
// Aktive Schueler (letzte 60 Minuten)
$activeStudents = $db->fetchAll(
'SELECT id, display_name, last_seen FROM student_sessions
WHERE class_id = ? AND last_seen > DATE_SUB(NOW(), INTERVAL 60 MINUTE)
ORDER BY last_seen DESC',
[$classId]
);
// Alle Schueler der Klasse
$allStudents = $db->fetchAll(
'SELECT id, display_name, created_at, last_seen FROM student_sessions WHERE class_id = ? ORDER BY display_name',
[$classId]
);
// Assessments fuer diese Klasse
$assessments = $db->fetchAll(
'SELECT a.*, s.display_name FROM assessments a
JOIN student_sessions s ON s.id = a.session_id
WHERE a.class_id = ?
ORDER BY a.submitted_at DESC
LIMIT 100',
[$classId]
);
// Modulfreigaben
$modules = $db->fetchAll('SELECT * FROM class_modules WHERE class_id = ?', [$classId]);
Response::ok([
'classes' => $classes,
'currentClassId' => $classId,
'activeStudents' => $activeStudents,
'allStudents' => $allStudents,
'assessments' => $assessments,
'modules' => $modules,
]);
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* API: Spielstaende speichern/laden
* GET /api/saves?key=klimawaechter-save → Spielstand laden
* POST /api/saves {key, data, version} → Spielstand speichern
*/
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
if ($method === 'GET') {
$sessionId = Session::studentId();
if (!$sessionId) {
Response::json(['data' => null]);
}
$key = $_GET['key'] ?? '';
if (!$key) Response::error('key fehlt');
$row = $db->fetchOne(
'SELECT save_data, save_version FROM game_saves WHERE session_id = ? AND save_key = ?',
[$sessionId, $key]
);
Response::json(['data' => $row ? $row['save_data'] : null, 'version' => $row ? (int)$row['save_version'] : null]);
}
if ($method === 'POST') {
$sessionId = Session::requireStudent();
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || !isset($body['key']) || !isset($body['data'])) {
Response::error('key und data erforderlich');
}
$db->execute(
'INSERT INTO game_saves (session_id, save_key, save_data, save_version)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE save_data = VALUES(save_data), save_version = VALUES(save_version)',
[$sessionId, $body['key'], $body['data'], $body['version'] ?? 2]
);
Response::ok();
}
Response::error('Methode nicht erlaubt', 405);
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* API: Session-Management
* POST /api/sessions {action: "join", joinCode: "ABC123", displayName: "Anna"}
* POST /api/sessions {action: "status"}
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
Response::error('Nur POST erlaubt', 405);
}
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
$db = Database::get();
if ($action === 'join') {
$joinCode = strtoupper(trim($body['joinCode'] ?? ''));
if (strlen($joinCode) !== 6) {
Response::error('Ungültiger Code');
}
$class = $db->fetchOne('SELECT id, name FROM classes WHERE join_code = ?', [$joinCode]);
if (!$class) {
Response::error('Klasse nicht gefunden');
}
$displayName = trim($body['displayName'] ?? '');
$uuid = Session::createStudent((int)$class['id'], $displayName);
Response::ok([
'sessionId' => $uuid,
'className' => $class['name'],
'classId' => (int)$class['id'],
]);
}
if ($action === 'status') {
$sessionId = Session::studentId();
if (!$sessionId) {
Response::json(['loggedIn' => false]);
}
$session = $db->fetchOne(
'SELECT s.display_name, s.class_id, c.name as class_name
FROM student_sessions s
LEFT JOIN classes c ON c.id = s.class_id
WHERE s.id = ?',
[$sessionId]
);
if (!$session) {
Response::json(['loggedIn' => false]);
}
// last_seen aktualisieren
$db->execute('UPDATE student_sessions SET last_seen = NOW() WHERE id = ?', [$sessionId]);
Response::json([
'loggedIn' => true,
'sessionId' => $sessionId,
'displayName' => $session['display_name'],
'classId' => $session['class_id'],
'className' => $session['class_name'],
]);
}
Response::error('Unbekannte Aktion');
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* GeoGraSim — App Configuration
* Umgebungserkennung und Grundkonstanten
*/
// Umgebungserkennung via Hostname
define('IS_PRODUCTION', isset($_SERVER['HTTP_HOST']) && $_SERVER['HTTP_HOST'] !== 'localhost');
define('BASE_PATH', '/geograsim/App');
define('BASE_URL', (IS_PRODUCTION ? 'https://www.staatsgeheimnis.at' : 'http://localhost') . BASE_PATH);
define('APP_ROOT', dirname(__DIR__, 2));
// .env laden
$envFile = IS_PRODUCTION
? APP_ROOT . '/.env.production'
: APP_ROOT . '/.env.local';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (!defined($key)) {
define($key, $value);
}
}
}
// Defaults fuer lokale Entwicklung (XAMPP)
if (!defined('DB_HOST')) define('DB_HOST', 'localhost');
if (!defined('DB_NAME')) define('DB_NAME', 'geograsim');
if (!defined('DB_USER')) define('DB_USER', 'root');
if (!defined('DB_PASS')) define('DB_PASS', '');
if (!defined('DB_PORT')) define('DB_PORT', '3306');
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* GeoGraSim — Database Connection (PDO)
*/
require_once __DIR__ . '/app.php';
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME);
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* GeoGraSim — Thin Database Wrapper
*/
require_once __DIR__ . '/../config/db.php';
class Database {
private PDO $pdo;
private static ?Database $instance = null;
private function __construct() {
$this->pdo = getDB();
}
public static function get(): Database {
if (self::$instance === null) {
self::$instance = new Database();
}
return self::$instance;
}
public function query(string $sql, array $params = []): PDOStatement {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetchOne(string $sql, array $params = []): ?array {
$stmt = $this->query($sql, $params);
$row = $stmt->fetch();
return $row ?: null;
}
public function fetchAll(string $sql, array $params = []): array {
return $this->query($sql, $params)->fetchAll();
}
public function execute(string $sql, array $params = []): int {
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function lastInsertId(): string {
return $this->pdo->lastInsertId();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* GeoGraSim — JSON Response Helpers
*/
class Response {
public static function json($data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
public static function error(string $message, int $status = 400): void {
self::json(['error' => $message], $status);
}
public static function ok($data = null): void {
self::json($data ?? ['ok' => true]);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* GeoGraSim — Session Management
* Schueler: UUID-Cookie (anonym, kein Login)
* Lehrkraefte: PHP-Session mit teacher_id
*/
class Session {
const COOKIE_NAME = 'ggs_session';
const COOKIE_TTL = 86400 * 30; // 30 Tage
public static function start(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
/** Schueler-Session-ID aus Cookie */
public static function studentId(): ?string {
return $_COOKIE[self::COOKIE_NAME] ?? null;
}
/** Neue Schueler-Session erstellen */
public static function createStudent(int $classId, string $displayName = ''): string {
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$db = Database::get();
$db->execute(
'INSERT INTO student_sessions (id, class_id, display_name) VALUES (?, ?, ?)',
[$uuid, $classId, $displayName]
);
setcookie(self::COOKIE_NAME, $uuid, [
'expires' => time() + self::COOKIE_TTL,
'path' => BASE_PATH . '/',
'samesite' => 'Lax',
'secure' => IS_PRODUCTION,
'httponly' => true,
]);
return $uuid;
}
/** API-Guard: 401 wenn keine Session */
public static function requireStudent(): string {
$id = self::studentId();
if (!$id) {
http_response_code(401);
echo json_encode(['error' => 'Keine Session']);
exit;
}
return $id;
}
/** Lehrer eingeloggt? */
public static function teacherId(): ?int {
return $_SESSION['teacher_id'] ?? null;
}
/** Lehrer-Login */
public static function loginTeacher(int $teacherId): void {
self::start();
$_SESSION['teacher_id'] = $teacherId;
}
/** Lehrer-Guard: 401 wenn nicht eingeloggt */
public static function requireTeacher(): int {
$id = self::teacherId();
if (!$id) {
http_response_code(401);
echo json_encode(['error' => 'Nicht eingeloggt']);
exit;
}
return $id;
}
public static function logout(): void {
self::start();
session_destroy();
setcookie(self::COOKIE_NAME, '', ['expires' => 1, 'path' => BASE_PATH . '/']);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* Vite Manifest Reader
* Liest dist/.vite/manifest.json und gibt die korrekten Script-Tags aus.
* Im Dev-Modus (kein manifest vorhanden) wird direkt auf die TS-Quellen verwiesen.
*/
function viteAssets(string $entry): void {
$manifestPath = APP_ROOT . '/dist/.vite/manifest.json';
// Production: Lese Manifest
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
$chunk = $manifest[$entry] ?? null;
if (!$chunk) return;
// CSS
foreach ($chunk['css'] ?? [] as $css) {
echo '<link rel="stylesheet" href="' . BASE_PATH . '/dist/' . $css . '">' . "\n";
}
// Preload imports
foreach ($chunk['imports'] ?? [] as $importKey) {
$imp = $manifest[$importKey] ?? null;
if ($imp) {
echo '<link rel="modulepreload" crossorigin href="' . BASE_PATH . '/dist/' . $imp['file'] . '">' . "\n";
}
}
// Main entry
echo '<script type="module" crossorigin src="' . BASE_PATH . '/dist/' . $chunk['file'] . '"></script>' . "\n";
}
// Dev fallback: keine Manifest-Datei → direkte TS-Referenz (Vite Dev Server)
}
/** Session-Kontext als JS-Variable injizieren */
function injectSessionContext(): void {
$ctx = [
'sessionId' => Session::studentId(),
'teacherId' => Session::teacherId(),
'baseUrl' => BASE_URL,
'basePath' => BASE_PATH,
];
echo '<script>window.__GGS__ = ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . ';</script>' . "\n";
}
+518
View File
@@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Forscher*in im Regenwald</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --sand-l: #f2eacc;
}
html, body { height:100%; overflow:hidden; }
body { font-family:'Inter',system-ui,sans-serif; color:var(--text); }
/* === FLUSS-KARTE (scrollender Hintergrund) === */
#river-scene {
position: fixed; inset: 0; z-index: 0;
overflow: hidden;
/* Der Gradient wandert von unten (Delta/warm) nach oben (Berg/kühl) */
background: linear-gradient(0deg, #a8d8c8 0%, #5a9a6a 40%, #3a7a4a 70%, #2a5a3a 100%);
}
/* Flussbett — ein vertikaler Streifen in der Mitte */
#river-bed {
position: absolute;
left: 50%; transform: translateX(-50%);
width: 140px;
top: 0; bottom: 0;
background: linear-gradient(180deg, rgba(60,120,160,.5) 0%, rgba(40,90,130,.65) 100%);
border-left: 2px solid rgba(40,80,120,.3);
border-right: 2px solid rgba(40,80,120,.3);
border-radius: 10px;
}
/* Wellen-Effekt (CSS, keine Sprites) */
#river-bed::before {
content: '';
position: absolute; inset: 0;
background: repeating-linear-gradient(
0deg,
transparent 0px,
transparent 28px,
rgba(255,255,255,.12) 30px,
transparent 32px
);
animation: wave 3s linear infinite;
}
@keyframes wave { from { background-position-y: 0; } to { background-position-y: 60px; } }
/* === UFER-BÄUME (einfache Emoji-Reihen links und rechts) === */
.tree-row {
position: absolute; top: 0; bottom: 0;
width: 80px;
display: flex; flex-direction: column; gap: 35px;
padding-top: 20px;
font-size: 1.6rem; line-height: 1;
overflow: hidden;
pointer-events: none;
}
.tree-row.left { left: calc(50% - 140px); text-align: right; }
.tree-row.right { right: calc(50% - 140px); text-align: left; }
/* === BOOT === */
#boat {
position: fixed;
left: 50%; bottom: 38%;
transform: translateX(-50%);
font-size: 2.6rem;
z-index: 10;
transition: bottom .8s cubic-bezier(.22,1,.36,1);
filter: drop-shadow(0 4px 8px rgba(0,0,0,.25));
pointer-events: none;
}
/* === ENTDECKUNGS-MARKER === */
.discovery-marker {
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 8;
cursor: pointer;
transition: all .25s cubic-bezier(.22,1,.36,1);
}
.dm-bubble {
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
border-radius: 50%;
background: rgba(255,255,255,.85);
box-shadow: 0 4px 16px rgba(0,40,30,.22);
border: 2.5px solid rgba(255,255,255,.8);
font-size: 1.7rem;
transition: all .2s;
}
.dm-bubble:hover { transform: scale(1.2); box-shadow: 0 6px 24px rgba(0,40,30,.35); }
.dm-bubble.found { border-color: var(--moss); background: var(--moss-l); }
.dm-bubble.mystery {
background: rgba(255,255,255,.5);
border: 2.5px dashed rgba(0,0,0,.2);
font-size: 1.4rem; color: var(--text3);
}
.dm-name {
text-align: center;
font-size: .62rem; font-weight: 700;
color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,.5);
margin-top: .2rem;
white-space: nowrap;
}
/* === INFO-KARTE (erscheint bei Klick auf ein Tier) === */
.info-overlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(15,35,25,.75); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
padding: 1.5rem;
animation: fadein .25s ease-out;
}
@keyframes fadein { from { opacity:0; } to { opacity:1; } }
.info-card {
background: #fff; border-radius: 20px;
padding: 1.8rem 2rem; max-width: 480px; width: 100%;
box-shadow: 0 30px 80px rgba(0,0,0,.4);
animation: cardpop .3s cubic-bezier(.22,1,.36,1);
}
@keyframes cardpop {
from { transform: scale(.9) translateY(15px); opacity:0; }
to { transform: scale(1) translateY(0); opacity:1; }
}
.info-card .ic-emoji { font-size: 3.5rem; text-align: center; margin-bottom: .5rem; }
.info-card h3 { font-size: 1.3rem; font-weight: 800; color: var(--fjord); margin-bottom: .5rem; text-align: center; }
.info-card .ic-zone {
text-align: center; font-size: .62rem; font-weight: 700;
color: var(--moss); text-transform: uppercase; letter-spacing: .06em;
margin-bottom: .8rem;
}
.info-card p { font-size: .95rem; color: var(--text2); line-height: 1.6; margin-bottom: .7rem; }
.info-card .ic-wow {
background: var(--sand-l); border-left: 3px solid var(--sand);
padding: .7rem .9rem; border-radius: 0 8px 8px 0;
font-size: .85rem; color: var(--text2); margin-bottom: .9rem;
}
.info-card .ic-wow::before { content: '🤯 '; }
.info-card .ic-btn {
width: 100%; padding: .7rem; border: none; border-radius: 10px;
background: var(--moss); color: #fff;
font-weight: 700; font-size: .9rem; cursor: pointer;
font-family: inherit;
}
.info-card .ic-btn:hover { background: #4a7a4e; }
/* === TOPBAR (sehr schlank) === */
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 20;
padding: .3rem .8rem;
display: flex; justify-content: space-between; align-items: center;
background: rgba(255,255,255,.75);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0,0,0,.08);
}
.topbar .brand {
display: flex; align-items: center; gap: .45rem;
font-weight: 800; font-size: .85rem; color: var(--fjord);
text-decoration: none;
}
.topbar .brand img { width: 22px; height: 22px; border-radius: 5px; }
/* === SAMMELHEFT (rechts oben) === */
.sammelheft {
position: fixed; top: 3.2rem; right: .65rem; z-index: 15;
background: rgba(255,255,255,.88);
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,.6);
box-shadow: 0 8px 28px rgba(20,40,60,.18);
border-radius: 14px;
padding: .6rem .75rem;
width: 200px;
}
.sammelheft h4 {
font-size: .62rem; font-weight: 800; color: var(--fjord);
text-transform: uppercase; letter-spacing: .07em;
margin-bottom: .4rem;
}
.sh-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: .3rem;
}
.sh-slot {
width: 32px; height: 32px;
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
font-size: 1.15rem;
background: rgba(0,0,0,.06);
border: 1.5px solid rgba(0,0,0,.06);
transition: all .2s;
}
.sh-slot.found { background: var(--moss-l); border-color: var(--moss); }
.sh-slot.empty { color: var(--text3); font-size: .85rem; }
.sh-progress {
margin-top: .45rem;
font-size: .62rem; font-weight: 700; color: var(--text2);
text-align: center;
}
.sh-bar { height: 4px; background: rgba(0,0,0,.08); border-radius: 2px; margin-top: .2rem; overflow: hidden; }
.sh-bar-fill { height: 100%; background: var(--moss); border-radius: 2px; transition: width .4s; }
/* === ZONEN-ANZEIGE (unten links, zeigt aktuelle Zone) === */
.zone-badge {
position: fixed; bottom: .8rem; left: .8rem; z-index: 15;
background: rgba(255,255,255,.82);
backdrop-filter: blur(10px);
border-radius: 12px; padding: .5rem .8rem;
box-shadow: 0 4px 16px rgba(0,0,0,.12);
font-size: .72rem; font-weight: 700; color: var(--text);
}
.zone-badge .zb-emoji { font-size: 1.1rem; margin-right: .3rem; }
.zone-badge .zb-name { color: var(--moss); }
/* === STEUERUNGS-HINT (unten mitte) === */
.controls-hint {
position: fixed; bottom: .8rem; left: 50%; transform: translateX(-50%);
z-index: 15;
background: rgba(255,255,255,.75);
backdrop-filter: blur(10px);
border-radius: 10px; padding: .4rem .8rem;
box-shadow: 0 4px 12px rgba(0,0,0,.1);
font-size: .65rem; color: var(--text2); font-weight: 600;
}
.controls-hint kbd {
display: inline-block; padding: 1px 5px;
background: rgba(0,0,0,.08); border: 1px solid rgba(0,0,0,.12);
border-radius: 3px; font-size: .62rem; font-weight: 800;
font-family: ui-monospace, monospace;
}
/* === GLÜCKWUNSCH-OVERLAY === */
.congrats-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(15,35,25,.85); backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
padding: 1.5rem;
}
.congrats-card {
background: #fff; border-radius: 20px; padding: 2rem 2.5rem;
max-width: 480px; text-align: center;
box-shadow: 0 30px 80px rgba(0,0,0,.4);
}
.congrats-card .cc-emoji { font-size: 4rem; margin-bottom: .5rem; }
.congrats-card h2 { font-size: 1.6rem; font-weight: 900; color: var(--fjord); margin-bottom: .5rem; }
.congrats-card p { font-size: .92rem; color: var(--text2); line-height: 1.6; margin-bottom: 1rem; }
.congrats-card .cc-stat { font-size: 1.1rem; font-weight: 800; color: var(--moss); margin-bottom: 1rem; }
.congrats-card .ic-btn { width: 100%; }
</style>
</head>
<body>
<!-- TOPBAR -->
<nav class="topbar">
<a href="index.html" class="brand">
<img src="assets/img/logo.png" alt="">
Forscher*in im Regenwald 🌴
</a>
<a href="index.html" class="brand" style="font-size:.7rem;opacity:.6;">← Zurück</a>
</nav>
<!-- SAMMELHEFT -->
<div class="sammelheft" id="sammelheft">
<h4>📔 Sammelheft</h4>
<div class="sh-grid" id="sh-grid"></div>
<div class="sh-progress" id="sh-progress">0 von 15 entdeckt</div>
<div class="sh-bar"><div class="sh-bar-fill" id="sh-bar-fill" style="width:0%"></div></div>
</div>
<!-- ZONEN-ANZEIGE -->
<div class="zone-badge" id="zone-badge">
<span class="zb-emoji">🏝</span>
<span class="zb-name">Flussmündung</span>
</div>
<!-- STEUERUNGS-HINT -->
<div class="controls-hint">
<kbd></kbd> flussaufwärts · <kbd></kbd> flussabwärts · Klick auf ein Tier zum Entdecken
</div>
<!-- FLUSS-SZENE -->
<div id="river-scene">
<div id="river-bed"></div>
<div class="tree-row left" id="trees-left"></div>
<div class="tree-row right" id="trees-right"></div>
<!-- Entdeckungs-Marker werden hier per JS eingefügt -->
</div>
<!-- BOOT -->
<div id="boat">🛶</div>
<!-- INFO-KARTE HOST -->
<div id="info-host"></div>
<!-- GLÜCKWUNSCH HOST -->
<div id="congrats-host"></div>
<script type="module">
import { DISCOVERIES, ZONES } from './src/sims/sim-11-regenwald/data.ts'
// ===================================================================
// STATE
// ===================================================================
const SAVE_KEY = 'regenwald-discovered'
let discovered = new Set()
try {
const saved = JSON.parse(localStorage.getItem(SAVE_KEY) || '[]')
if (Array.isArray(saved)) discovered = new Set(saved)
} catch {}
// Scroll-Position: 0 = Flussmündung (unten), 100 = Bergregenwald (oben)
let scrollPos = 0
const SCROLL_SPEED = 0.6
const SCENE_HEIGHT_VH = 350 // virtuelle Höhe der Szene in vh-Einheiten
function save() {
localStorage.setItem(SAVE_KEY, JSON.stringify([...discovered]))
}
// ===================================================================
// BÄUME AM UFER (deko, statisch)
// ===================================================================
function buildTrees() {
const treeEmojis = ['🌴', '🌳', '🌿', '🎋', '🌲']
const count = 35
for (const side of ['left', 'right']) {
const el = document.getElementById(`trees-${side}`)
let html = ''
for (let i = 0; i < count; i++) {
const emoji = treeEmojis[Math.floor(Math.random() * treeEmojis.length)]
const offset = side === 'left'
? `padding-right:${Math.random() * 30}px`
: `padding-left:${Math.random() * 30}px`
html += `<div style="${offset}">${emoji}</div>`
}
el.innerHTML = html
}
}
buildTrees()
// ===================================================================
// ENTDECKUNGS-MARKER
// ===================================================================
const scene = document.getElementById('river-scene')
const markerEls = []
for (const d of DISCOVERIES) {
const marker = document.createElement('div')
marker.className = 'discovery-marker'
marker.dataset.id = d.id
// Position: 0 = unten (100vh), 100 = oben (0vh)
// Wir setzen die Position relativ zur Szene-Höhe (SCENE_HEIGHT_VH)
const topPct = (1 - d.position / 100) * SCENE_HEIGHT_VH
marker.style.top = `${topPct}vh`
// Leicht links/rechts versetzen (abwechselnd)
const xOff = (DISCOVERIES.indexOf(d) % 2 === 0 ? -45 : 45)
marker.style.marginLeft = `${xOff}px`
const isFound = discovered.has(d.id)
marker.innerHTML = `
<div class="dm-bubble ${isFound ? 'found' : 'mystery'}">
${isFound ? d.emoji : '❓'}
</div>
<div class="dm-name">${isFound ? d.name : '???'}</div>
`
marker.addEventListener('click', () => showInfo(d))
scene.appendChild(marker)
markerEls.push({ marker, data: d })
}
// ===================================================================
// SAMMELHEFT
// ===================================================================
function renderSammelheft() {
const grid = document.getElementById('sh-grid')
grid.innerHTML = DISCOVERIES.map(d => {
const found = discovered.has(d.id)
return `<div class="sh-slot ${found ? 'found' : 'empty'}" title="${found ? d.name : '???'}">
${found ? d.emoji : '?'}
</div>`
}).join('')
const count = discovered.size
document.getElementById('sh-progress').textContent = `${count} von ${DISCOVERIES.length} entdeckt`
document.getElementById('sh-bar-fill').style.width = `${(count / DISCOVERIES.length) * 100}%`
}
renderSammelheft()
// ===================================================================
// ZONEN-BADGE
// ===================================================================
function updateZoneBadge() {
let zone = ZONES[0]
if (scrollPos > 33) zone = ZONES[1]
if (scrollPos > 66) zone = ZONES[2]
const badge = document.getElementById('zone-badge')
badge.innerHTML = `<span class="zb-emoji">${zone.emoji}</span> <span class="zb-name">${zone.name}</span> — ${zone.description}`
}
updateZoneBadge()
// ===================================================================
// SCROLL-MECHANIK (Pfeiltasten)
// ===================================================================
const keysDown = new Set()
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
keysDown.add(e.key)
}
})
window.addEventListener('keyup', (e) => keysDown.delete(e.key))
// Mausrad scrollt auch
window.addEventListener('wheel', (e) => {
e.preventDefault()
scrollPos = Math.max(0, Math.min(100, scrollPos + (e.deltaY > 0 ? -SCROLL_SPEED * 2 : SCROLL_SPEED * 2)))
applyScroll()
}, { passive: false })
function applyScroll() {
// Szene verschieben: 0 = Boden, 100 = Spitze
const translateY = scrollPos / 100 * (SCENE_HEIGHT_VH - 100)
scene.style.transform = `translateY(${translateY}vh)`
// Hintergrund-Gradient anpassen
const zone = scrollPos < 33 ? 0 : scrollPos < 66 ? 1 : 2
const z = ZONES[zone]
scene.style.background = `linear-gradient(0deg, ${z.bgFrom} 0%, ${z.bgTo} 100%)`
updateZoneBadge()
}
// rAF-Loop für Pfeiltasten-Scroll
function scrollLoop() {
if (keysDown.has('ArrowUp')) scrollPos = Math.min(100, scrollPos + SCROLL_SPEED)
if (keysDown.has('ArrowDown')) scrollPos = Math.max(0, scrollPos - SCROLL_SPEED)
if (keysDown.size > 0) applyScroll()
requestAnimationFrame(scrollLoop)
}
requestAnimationFrame(scrollLoop)
// ===================================================================
// INFO-KARTE
// ===================================================================
function showInfo(d) {
// Entdeckung registrieren
const wasNew = !discovered.has(d.id)
discovered.add(d.id)
save()
// Marker aktualisieren
const mel = markerEls.find(m => m.data.id === d.id)
if (mel) {
const bubble = mel.marker.querySelector('.dm-bubble')
bubble.className = 'dm-bubble found'
bubble.textContent = d.emoji
mel.marker.querySelector('.dm-name').textContent = d.name
}
renderSammelheft()
// Info-Overlay
const host = document.getElementById('info-host')
const zone = ZONES.find(z => z.id === d.zone)
host.innerHTML = `
<div class="info-overlay" id="info-bg">
<div class="info-card">
<div class="ic-emoji">${d.emoji}</div>
<h3>${d.name}</h3>
<div class="ic-zone">${zone?.emoji || ''} ${zone?.name || d.zone}</div>
<p>${d.fact}</p>
${d.wow ? `<div class="ic-wow">${d.wow}</div>` : ''}
<button class="ic-btn" id="info-close">
${wasNew ? '✅ Entdeckt! Weiter' : 'Verstanden ✓'}
</button>
</div>
</div>
`
document.getElementById('info-close')?.addEventListener('click', closeInfo)
document.getElementById('info-bg')?.addEventListener('click', (e) => {
if (e.target.id === 'info-bg') closeInfo()
})
// Prüfen ob alle gefunden
if (discovered.size === DISCOVERIES.length) {
setTimeout(showCongrats, 800)
}
}
function closeInfo() {
document.getElementById('info-host').innerHTML = ''
}
// ===================================================================
// GLÜCKWUNSCH
// ===================================================================
function showCongrats() {
const host = document.getElementById('congrats-host')
host.innerHTML = `
<div class="congrats-overlay">
<div class="congrats-card">
<div class="cc-emoji">🏆🌴</div>
<h2>Alle Tiere entdeckt!</h2>
<div class="cc-stat">${DISCOVERIES.length} von ${DISCOVERIES.length} gefunden</div>
<p>Du hast den ganzen Regenwald erforscht — von der Flussmündung bis zum Bergregenwald.</p>
<p>Aber vergiss nicht: Jedes Jahr verschwindet eine Regenwald-Fläche so groß wie die Schweiz. Was wir kennen, können wir schützen. 🌍</p>
<button class="ic-btn" onclick="this.closest('.congrats-overlay').remove()">Nochmal erkunden</button>
</div>
</div>
`
}
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
-- GeoGraSim — MySQL Schema
-- Ausfuehren: mysql -u root geograsim < schema.sql
CREATE DATABASE IF NOT EXISTS geograsim CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE geograsim;
-- Lehrkraefte
CREATE TABLE IF NOT EXISTS teachers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
display_name VARCHAR(128),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Klassen
CREATE TABLE IF NOT EXISTS classes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
teacher_id INT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
school_year VARCHAR(10),
join_code CHAR(6) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (teacher_id) REFERENCES teachers(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Schueler-Sessions (anonym, UUID-Cookie)
CREATE TABLE IF NOT EXISTS student_sessions (
id CHAR(36) PRIMARY KEY,
class_id INT UNSIGNED,
display_name VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Spielstaende (ersetzt localStorage)
CREATE TABLE IF NOT EXISTS game_saves (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id CHAR(36) NOT NULL,
save_key VARCHAR(64) NOT NULL,
save_data MEDIUMTEXT NOT NULL,
save_version TINYINT UNSIGNED DEFAULT 2,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_session_key (session_id, save_key),
FOREIGN KEY (session_id) REFERENCES student_sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Assessment-Daten (eine Zeile pro Simulationsabschluss)
CREATE TABLE IF NOT EXISTS assessments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id CHAR(36) NOT NULL,
sim_id VARCHAR(16) NOT NULL,
class_id INT UNSIGNED,
process_log JSON,
predictions JSON,
results JSON,
reflections JSON,
duration_ms INT UNSIGNED,
completed_phases JSON,
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_class_sim (class_id, sim_id),
INDEX idx_session (session_id),
FOREIGN KEY (session_id) REFERENCES student_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Modulfreigabe pro Klasse
CREATE TABLE IF NOT EXISTS class_modules (
class_id INT UNSIGNED NOT NULL,
module_id VARCHAR(16) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
PRIMARY KEY (class_id, module_id),
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Demo-Lehrer anlegen (Passwort: "demo2026")
INSERT IGNORE INTO teachers (id, username, password, display_name)
VALUES (1, 'demo', '$2y$10$YQ8kXE5Bq3Dq1Z5VxGf1/.J9Cxj5xEqL8RyE3.zDZ1YCbYdLiK2Vy', 'Demo-Lehrkraft');
-- Demo-Klasse
INSERT IGNORE INTO classes (id, teacher_id, name, school_year, join_code)
VALUES (1, 1, '1A', '2025/26', 'GEO1A');
+288
View File
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Simulation</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #f7f6f3; --white: #fff;
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
--moss: #5a8a5e; --moss-l: #dceadd;
--sand: #c4a35a; --coral: #c07a6b; --coral-l: #f2ddd8;
}
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); }
.nav { padding:.7rem 2rem; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid rgba(0,0,0,.04); background:var(--white); }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:1rem; color:var(--fjord); text-decoration:none; }
.nav-brand img { width:28px; height:28px; border-radius:6px; }
.nav-back { color:var(--text2); text-decoration:none; font-size:.82rem; font-weight:500; padding:.4rem .8rem; border-radius:8px; border:1px solid rgba(0,0,0,.08); }
.nav-back:hover { background:var(--fjord-l); }
.shell { max-width:1100px; margin:1.5rem auto; padding:0 1.5rem; }
.shell-header { margin-bottom:1rem; }
.shell-header h1 { font-size:1.6rem; font-weight:800; letter-spacing:-.02em; }
.shell-meta { display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.3rem; }
.tag { font-size:.68rem; font-weight:600; padding:2px 9px; border-radius:6px; }
.tag-kl { background:var(--fjord-l); color:var(--fjord); }
.tag-time { background:#f0eeea; color:var(--text3); }
.tag-typ { background:var(--moss-l); color:var(--moss); }
.poe { display:flex; gap:.3rem; margin-bottom:1.2rem; }
.poe-step { flex:1; padding:.5rem .3rem; border-radius:8px; text-align:center; font-size:.7rem; font-weight:600; background:#f0eeea; color:var(--text3); transition:all .3s; }
.poe-step.active { background:var(--fjord); color:#fff; box-shadow:0 2px 8px rgba(74,124,138,.25); }
.poe-step.done { background:var(--moss-l); color:var(--moss); }
.sim-layout { display:grid; grid-template-columns:1fr 270px; gap:1rem; }
.sim-main { display:flex; flex-direction:column; gap:1rem; }
.sim-canvas-wrap { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.05); overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,.05); }
.sim-sidebar { display:flex; flex-direction:column; gap:.8rem; }
.panel { background:var(--white); border-radius:14px; border:1px solid rgba(0,0,0,.05); padding:1.1rem; box-shadow:0 2px 10px rgba(0,0,0,.03); }
.panel h4 { font-size:.72rem; font-weight:700; color:var(--fjord); text-transform:uppercase; letter-spacing:.06em; margin-bottom:.6rem; }
.panel p { font-size:.85rem; line-height:1.55; }
.ctrl { margin-bottom:.7rem; }
.ctrl label { display:flex; justify-content:space-between; font-size:.78rem; font-weight:500; margin-bottom:.15rem; }
.ctrl label .val { color:var(--fjord); font-weight:700; }
.ctrl input[type=range] { width:100%; -webkit-appearance:none; height:5px; border-radius:3px; background:#e8e8e4; outline:none; }
.ctrl input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:18px; height:18px; border-radius:50%; background:var(--fjord); cursor:pointer; box-shadow:0 1px 5px rgba(0,0,0,.15); }
.lz h4 { color:var(--moss); }
.lz ul { font-size:.75rem; color:var(--text2); padding-left:1rem; }
.lz li { margin-bottom:.15rem; }
.predict-panel { background:var(--fjord-l); border:1px solid rgba(74,124,138,.15); }
.predict-panel h4 { color:var(--fjord-d); }
.predict-panel textarea, .reflect-panel textarea {
width:100%; border:1px solid rgba(0,0,0,.08); border-radius:8px;
padding:.5rem; font-family:inherit; font-size:.8rem; resize:vertical; min-height:60px;
margin-top:.3rem;
}
.phase-btn { width:100%; padding:.7rem; border:none; border-radius:10px; font-weight:700; font-size:.85rem; cursor:pointer; background:var(--fjord); color:#fff; transition:all .2s; }
.phase-btn:hover { background:var(--fjord-d); transform:translateY(-1px); }
.phase-btn:disabled { background:var(--moss); cursor:default; }
@media (max-width:780px) {
.sim-layout { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">GeoGraSim</a>
<a href="index.html" class="nav-back">← Zurück</a>
</nav>
<div class="shell">
<div class="shell-header">
<h1 id="sim-title"></h1>
<div class="shell-meta">
<span class="tag tag-kl" id="sim-klasse"></span>
<span class="tag tag-time" id="sim-zeit"></span>
<span class="tag tag-typ" id="sim-typ"></span>
</div>
</div>
<div class="poe" id="poe">
<div class="poe-step active">📖 Intro</div>
<div class="poe-step">🤔 Vorhersage</div>
<div class="poe-step">🔬 Simulation</div>
<div class="poe-step">👁️ Beobachtung</div>
<div class="poe-step">💭 Reflexion</div>
</div>
<div class="sim-layout">
<div class="sim-main">
<div class="panel" id="intro-panel"></div>
<div class="panel predict-panel" id="predict-panel" style="display:none">
<h4>🤔 Deine Vorhersage</h4>
<p id="predict-question"></p>
<textarea id="predict-input" placeholder="Schreibe deine Vermutung hier..."></textarea>
</div>
<div class="sim-canvas-wrap" id="canvas-wrap" style="display:none"></div>
<div class="panel" id="reflect-panel" style="display:none">
<h4>💭 Reflexion</h4>
<p id="reflect-question"></p>
<textarea id="reflect-input" placeholder="Was hast du gelernt?"></textarea>
<p id="reflect-tip" style="font-size:.78rem;color:var(--text3);margin-top:.4rem"></p>
</div>
</div>
<div class="sim-sidebar">
<div class="panel" id="controls-panel">
<h4>⚙️ Parameter</h4>
<div id="controls-host"></div>
</div>
<div class="panel lz">
<h4>🎯 Lernziele</h4>
<ul id="lernziele-list"></ul>
</div>
<button class="phase-btn" id="phase-btn">Weiter → Vorhersage</button>
</div>
</div>
</div>
<script type="module">
import { TreibhausSimulation } from './src/sims/sim-05-treibhaus/logic.ts'
import { TreibhausRenderer } from './src/sims/sim-05-treibhaus/renderer.ts'
import { ErdbebenSimulation } from './src/sims/sim-07-erdbeben/logic.ts'
import { ErdbebenRenderer } from './src/sims/sim-07-erdbeben/renderer.ts'
import { EnergiemixSimulation } from './src/sims/sim-09-energiemix/logic.ts'
import { EnergiemixRenderer } from './src/sims/sim-09-energiemix/renderer.ts'
import { AUSTRIA, getLocalName } from './src/core/education-levels.ts'
// Sim registry
const SIMS = {
'05': {
SimClass: TreibhausSimulation,
RendererClass: TreibhausRenderer,
intro: `<h4>📖 Einführung</h4>
<p>Die Erde wird von der Sonne erwärmt. Ohne die Atmosphäre wäre es hier <strong>-18°C</strong> kalt — viel zu kalt für Leben.</p>
<p>Bestimmte Gase in der Atmosphäre — vor allem <strong>CO₂</strong> — fangen Wärmestrahlung ab und halten die Erde warm. Das nennt man den <strong>Treibhauseffekt</strong>.</p>
<p style="color:var(--fjord);font-weight:600">Aber was passiert, wenn sich die CO₂-Menge verändert? Finde es heraus!</p>`,
predictQ: 'Was denkst du: Wie warm wäre die Erde, wenn es <strong>doppelt so viel CO₂</strong> gäbe wie heute?',
reflectQ: 'Vergleiche das Ergebnis mit deiner Vorhersage. Was hat dich überrascht?',
reflectTip: '💡 Viele Menschen verwechseln den Treibhauseffekt mit dem Ozonloch — das sind aber zwei ganz verschiedene Dinge!',
},
'07': {
SimClass: ErdbebenSimulation,
RendererClass: ErdbebenRenderer,
intro: `<h4>📖 Einführung</h4>
<p>Die Erdkruste besteht aus <strong>tektonischen Platten</strong>, die sich langsam gegeneinander bewegen — nur wenige Zentimeter pro Jahr.</p>
<p>Wenn sie sich verhaken, baut sich <strong>Spannung</strong> auf. Wird die Spannung zu groß, ruckt es plötzlich los: ein <strong>Erdbeben</strong> entsteht.</p>
<p>Aber: Ein Beben gleicher Stärke kann sehr <strong>unterschiedliche Folgen</strong> haben — je nachdem, wie gut die Häuser gebaut sind.</p>
<p style="color:var(--fjord);font-weight:600">Beobachte zwei Städte: Stadt A ist wohlhabend, Stadt B nicht. Was passiert?</p>`,
predictQ: 'Was denkst du: Wenn ein Erdbeben der Stärke 7 in beiden Städten gleich stark ankommt — gibt es einen Unterschied bei den Schäden?',
reflectQ: 'Was bestimmt, wie schlimm ein Erdbeben für eine Region wird?',
reflectTip: '💡 Naturgefahren werden oft "natürlich" genannt — aber der Schaden ist meistens vom Menschen gemacht: durch Bauweise, Vorbereitung und Reichtum.',
},
'09': {
SimClass: EnergiemixSimulation,
RendererClass: EnergiemixRenderer,
intro: `<h4>📖 Einführung</h4>
<p>Strom muss irgendwo herkommen. Es gibt viele Möglichkeiten: Kohle, Gas, Atomkraft, Wind, Sonne, Wasser.</p>
<p>Jede Quelle hat <strong>Vor- und Nachteile</strong>: Manche sind billig, aber dreckig. Andere sauber, aber unzuverlässig oder teuer.</p>
<p style="color:var(--fjord);font-weight:600">Stelle deinen eigenen Energiemix zusammen. Schaffst du es, die drei Ziele in Einklang zu bringen?</p>`,
predictQ: 'Was denkst du: Welcher Mix wäre am besten — möglichst viel Erneuerbare? Oder doch ein Mix mit Kernenergie?',
reflectQ: 'Welche Trade-offs hast du erlebt? Gibt es einen "perfekten" Energiemix?',
reflectTip: '💡 Es gibt keine perfekte Lösung — Energiepolitik ist immer eine Abwägung zwischen Klima, Kosten und Versorgungssicherheit.',
},
}
// Get sim ID from URL
const params = new URLSearchParams(location.search)
const simId = params.get('id') || '05'
const config = SIMS[simId] || SIMS['05']
// Create simulation
const sim = new config.SimClass()
let renderer = null
// Populate header
document.getElementById('sim-title').textContent = sim.meta.name
document.getElementById('sim-klasse').textContent = getLocalName(AUSTRIA, sim.meta.primaryLevel) || `Stufe ${sim.meta.primaryLevel}`
document.getElementById('sim-zeit').textContent = `${sim.meta.dpiMinuten} min`
document.getElementById('sim-typ').textContent = sim.meta.typ
// Populate intro
document.getElementById('intro-panel').innerHTML = config.intro
document.getElementById('predict-question').innerHTML = config.predictQ
document.getElementById('reflect-question').innerHTML = config.reflectQ
document.getElementById('reflect-tip').innerHTML = config.reflectTip
// Populate lernziele
const lzList = document.getElementById('lernziele-list')
sim.meta.lernziele.forEach(l => {
const li = document.createElement('li')
li.textContent = l
lzList.appendChild(li)
})
// Populate controls
const controlsHost = document.getElementById('controls-host')
const ranges = sim.getVariableRanges()
for (const [key, range] of Object.entries(ranges)) {
const div = document.createElement('div')
div.className = 'ctrl'
const isFloat = range.max <= 10 && range.max - range.min < 5
const step = isFloat ? 0.01 : 1
div.innerHTML = `
<label>${range.label} <span class="val">${range.default}${range.unit ? ' ' + range.unit : ''}</span></label>
<input type="range" min="${range.min}" max="${range.max}" value="${range.default}" step="${step}">
`
const input = div.querySelector('input')
const valSpan = div.querySelector('.val')
input.addEventListener('input', () => {
const v = parseFloat(input.value)
sim.setVariable(key, v)
valSpan.textContent = `${isFloat ? v.toFixed(2) : v}${range.unit ? ' ' + range.unit : ''}`
})
controlsHost.appendChild(div)
}
// Phase navigation
const phases = ['intro', 'predict', 'simulate', 'observe', 'reflect', 'complete']
let phaseIdx = 0
const btnLabels = ['Weiter → Vorhersage', 'Weiter → Simulation', 'Weiter → Beobachtung', 'Weiter → Reflexion', 'Abschließen']
const phaseBtn = document.getElementById('phase-btn')
const poeSteps = document.querySelectorAll('.poe-step')
phaseBtn.addEventListener('click', () => {
if (phases[phaseIdx] === 'predict') {
sim.setPrediction('free_text', document.getElementById('predict-input').value)
}
if (phases[phaseIdx] === 'reflect') {
sim.addReflection(document.getElementById('reflect-input').value)
}
phaseIdx++
if (phaseIdx >= phases.length - 1) {
phaseBtn.textContent = '✓ Fertig!'
phaseBtn.disabled = true
console.log('Assessment-Daten:', sim.getAssessmentData())
} else {
phaseBtn.textContent = btnLabels[phaseIdx] || 'Weiter'
}
sim.nextPhase()
updateUI()
})
function updateUI() {
const current = phases[phaseIdx]
poeSteps.forEach((el, i) => {
el.classList.remove('active', 'done')
if (i === phaseIdx) el.classList.add('active')
else if (i < phaseIdx) el.classList.add('done')
})
document.getElementById('intro-panel').style.display = current === 'intro' ? '' : 'none'
document.getElementById('predict-panel').style.display = current === 'predict' ? '' : 'none'
document.getElementById('reflect-panel').style.display = (current === 'reflect' || current === 'complete') ? '' : 'none'
const canvasWrap = document.getElementById('canvas-wrap')
const showCanvas = ['simulate', 'observe', 'reflect', 'complete'].includes(current)
canvasWrap.style.display = showCanvas ? '' : 'none'
if (showCanvas && !renderer) {
renderer = new config.RendererClass(canvasWrap, sim)
renderer.start()
}
}
updateUI()
</script>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
/**
* Bildungsstufen-Konfiguration
*
* Abstrahiert von länderspezifischen Bezeichnungen.
* Pro App-Instanz wird ein Land/Region konfiguriert,
* das die Zuordnung von internen Leveln zu lokalen Bezeichnungen steuert.
*
* Intern arbeiten wir mit:
* - `educationLevel`: 113 (= Schulstufe/Schuljahr, international vergleichbar)
* - `ageRange`: Alter der Zielgruppe
* - `readingLevel`: Lesekompetenz (none, basic, fluent)
*
* In der Entwicklung entscheiden wir pro Simulation:
* - Welche educationLevels werden unterstützt?
* - Braucht es eine Variante ohne Lesen (Grundstufe 1)?
* - Welche Sprachkomplexität ist angemessen?
*/
export type ReadingLevel = 'none' | 'basic' | 'fluent'
export interface EducationLevel {
/** Internationale Schulstufe (1 = 1. Schuljahr, 5 = 5. Schuljahr etc.) */
level: number
/** Typisches Alter */
ageMin: number
ageMax: number
/** Erwartete Lesekompetenz */
reading: ReadingLevel
/** Kann komplexe Texte verarbeiten? */
canProcessComplexText: boolean
}
export interface CountryConfig {
id: string
name: string
/** Zuordnung: lokale Bezeichnung → internationale Schulstufe */
stages: CountryStage[]
/** Welche Schulstufen deckt unser Produkt primär ab? */
primaryRange: { from: number; to: number }
/** Optionale Erweiterung (z.B. Volksschule) */
extendedRange?: { from: number; to: number }
}
export interface CountryStage {
/** Internationale Schulstufe */
level: number
/** Lokale Bezeichnung (z.B. "1. Klasse Mittelschule") */
localName: string
/** Kurzform (z.B. "1. Kl. MS") */
shortName: string
/** Schultyp-Bezeichnung */
schoolType: string
/** Fachbezeichnung für Geografie in dieser Stufe */
subjectName: string
/** Wochenstunden GW (falls bekannt) */
hoursPerWeek?: number
}
// ============================================================
// Länderkonfigurationen
// ============================================================
export const AUSTRIA: CountryConfig = {
id: 'at',
name: 'Österreich',
primaryRange: { from: 5, to: 8 }, // Mittelschule / AHS-Unterstufe
extendedRange: { from: 1, to: 4 }, // Volksschule (Sachunterricht)
stages: [
// Volksschule (optional, für spätere Erweiterung)
{ level: 1, localName: '1. Klasse Volksschule', shortName: '1. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
{ level: 2, localName: '2. Klasse Volksschule', shortName: '2. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
{ level: 3, localName: '3. Klasse Volksschule', shortName: '3. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
{ level: 4, localName: '4. Klasse Volksschule', shortName: '4. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
// Mittelschule / AHS-Unterstufe (= unser Hauptfokus)
{ level: 5, localName: '1. Klasse', shortName: '1. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
{ level: 6, localName: '2. Klasse', shortName: '2. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 1 },
{ level: 7, localName: '3. Klasse', shortName: '3. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
{ level: 8, localName: '4. Klasse', shortName: '4. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
],
}
export const GERMANY_BAYERN: CountryConfig = {
id: 'de-by',
name: 'Deutschland (Bayern)',
primaryRange: { from: 5, to: 10 },
stages: [
{ level: 5, localName: '5. Jahrgangsstufe', shortName: '5. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
{ level: 6, localName: '6. Jahrgangsstufe', shortName: '6. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 0 },
{ level: 7, localName: '7. Jahrgangsstufe', shortName: '7. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
{ level: 8, localName: '8. Jahrgangsstufe', shortName: '8. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
{ level: 9, localName: '9. Jahrgangsstufe', shortName: '9. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 0 },
{ level: 10, localName: '10. Jahrgangsstufe', shortName: '10. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
],
}
export const GERMANY_NRW: CountryConfig = {
id: 'de-nrw',
name: 'Deutschland (NRW)',
primaryRange: { from: 5, to: 10 },
stages: [
{ level: 5, localName: 'Klasse 5', shortName: 'Kl. 5', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
{ level: 6, localName: 'Klasse 6', shortName: 'Kl. 6', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
{ level: 7, localName: 'Klasse 7', shortName: 'Kl. 7', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
{ level: 8, localName: 'Klasse 8', shortName: 'Kl. 8', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 1 },
{ level: 9, localName: 'Klasse 9', shortName: 'Kl. 9', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
{ level: 10, localName: 'Klasse 10', shortName: 'Kl. 10', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 1 },
],
}
export const SWITZERLAND: CountryConfig = {
id: 'ch',
name: 'Schweiz',
primaryRange: { from: 7, to: 9 }, // Zyklus 3
extendedRange: { from: 3, to: 6 }, // Zyklus 2 (NMG)
stages: [
// Zyklus 2 (optional)
{ level: 3, localName: '3. Klasse', shortName: '3. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
{ level: 4, localName: '4. Klasse', shortName: '4. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
{ level: 5, localName: '5. Klasse', shortName: '5. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
{ level: 6, localName: '6. Klasse', shortName: '6. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
// Zyklus 3 (= unser Hauptfokus)
{ level: 7, localName: '1. Oberstufe', shortName: '1. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
{ level: 8, localName: '2. Oberstufe', shortName: '2. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
{ level: 9, localName: '3. Oberstufe', shortName: '3. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
],
}
// ============================================================
// Education-Level-Definitionen (länderunabhängig)
// ============================================================
export const EDUCATION_LEVELS: EducationLevel[] = [
{ level: 1, ageMin: 6, ageMax: 7, reading: 'none', canProcessComplexText: false },
{ level: 2, ageMin: 7, ageMax: 8, reading: 'basic', canProcessComplexText: false },
{ level: 3, ageMin: 8, ageMax: 9, reading: 'basic', canProcessComplexText: false },
{ level: 4, ageMin: 9, ageMax: 10, reading: 'fluent', canProcessComplexText: false },
{ level: 5, ageMin: 10, ageMax: 11, reading: 'fluent', canProcessComplexText: false },
{ level: 6, ageMin: 11, ageMax: 12, reading: 'fluent', canProcessComplexText: true },
{ level: 7, ageMin: 12, ageMax: 13, reading: 'fluent', canProcessComplexText: true },
{ level: 8, ageMin: 13, ageMax: 14, reading: 'fluent', canProcessComplexText: true },
{ level: 9, ageMin: 14, ageMax: 15, reading: 'fluent', canProcessComplexText: true },
{ level: 10, ageMin: 15, ageMax: 16, reading: 'fluent', canProcessComplexText: true },
]
// ============================================================
// Alle verfügbaren Länder
// ============================================================
export const ALL_COUNTRIES: CountryConfig[] = [
AUSTRIA,
GERMANY_BAYERN,
GERMANY_NRW,
SWITZERLAND,
]
// ============================================================
// Helper-Funktionen
// ============================================================
/** Gibt die lokale Bezeichnung für eine Schulstufe zurück */
export function getLocalName(country: CountryConfig, level: number): string | undefined {
return country.stages.find(s => s.level === level)?.localName
}
/** Gibt alle Stufen zurück, die im Hauptfokus des Landes liegen */
export function getPrimaryStages(country: CountryConfig): CountryStage[] {
return country.stages.filter(
s => s.level >= country.primaryRange.from && s.level <= country.primaryRange.to
)
}
/** Prüft, ob eine Schulstufe Lesekompetenz erfordert */
export function requiresReading(level: number): boolean {
const ed = EDUCATION_LEVELS.find(e => e.level === level)
return ed ? ed.reading !== 'none' : true
}
/** Gibt die Lesekompetenz für eine Stufe zurück */
export function getReadingLevel(level: number): ReadingLevel {
return EDUCATION_LEVELS.find(e => e.level === level)?.reading ?? 'fluent'
}
/** Gibt die Fachbezeichnung für eine Stufe in einem Land zurück */
export function getSubjectName(country: CountryConfig, level: number): string | undefined {
return country.stages.find(s => s.level === level)?.subjectName
}
+514
View File
@@ -0,0 +1,514 @@
/**
* Simulations-Engine — Generischer Mechanik-Layer
*
* Basisklasse für interaktive Simulationen:
* - Zeit-Loop (real-time mit pause/play/speed)
* - Tick-System (jeder Tick = 1 simulierte Zeiteinheit, z.B. 1 Jahr/Monat/Sekunde)
* - Score & Resources
* - Erfolgs-/Misserfolgs-Zustände
* - Tutorial-Phasen mit schrittweiser Freischaltung von Features
* - Save/Resume
* - Zeitreihen-Daten für Graphen
*
* Designprinzipien:
* - Fachlich korrekt: alle Werte sind echt, keine Fantasiedaten
* - Erlebbar: schmaler Grat zwischen Erfolg/Misserfolg
* - Klassenkompatibel: jederzeit abschließbar, speicherbar
* - Transparent: alle Werte sind sichtbar (keine versteckten Variablen)
*
* Hinweis: Klassen- und Variablennamen tragen weiter "Game..." als
* technische Bezeichnung. User-facing Texte sprechen aber von
* "Simulation" — siehe architektur.md.
*/
export type GameState = 'tutorial' | 'playing' | 'paused' | 'won' | 'lost' | 'complete'
export type GameSpeed = 0 | 1 | 2 | 4 // 0 = pause, 1 = normal, 2 = schnell, 4 = sehr schnell
export interface GameMeta {
id: string
title: string
description: string
/** Wie lange dauert ein Tick in echten Millisekunden bei Speed = 1? */
msPerTick: number
/** Was repräsentiert ein Tick in der Simulation? z.B. "Monat", "Jahr", "Sekunde" */
tickUnit: string
/** Maximale Anzahl Ticks (= Spielende durch Zeit) — 0 = unbegrenzt */
maxTicks: number
/** Wieviele Tutorial-Schritte hat das Spiel? */
tutorialSteps: number
}
export interface Resource {
id: string
name: string
icon: string
current: number
initial: number
min?: number
max?: number
unit: string
format?: (v: number) => string
}
export interface GoalDef {
id: string
title: string
description: string
/** Bedingung erfüllt? Erhält Game-Snapshot */
check: (game: GameEngine) => boolean
/** Optional: numerischer Fortschritt 0-100 */
progress?: (game: GameEngine) => number
/** Verpflichtend für Win, oder Bonus? */
required: boolean
}
export interface TimelineEntry {
tick: number
values: Record<string, number>
}
export interface TutorialStep {
/** Wann zeigt sich dieser Step (welcher Tick) */
triggerTick: number
title: string
text: string
/** Optional: welche UI-Elemente werden in diesem Step sichtbar */
unlocks?: string[]
}
export interface GameEvent {
tick: number
type: string
text: string
severity: 'info' | 'success' | 'warning' | 'danger'
/** Optional: Schlüssel für ein Info-Overlay (kindgerechter Erklärtext) */
infoKey?: string
}
/**
* Eine Bürger-Beschwerde / Forderung mit interaktiver Wahl.
* Wird vom Spiel ausgelöst und vom Spieler entschieden — die Wahl
* hat reale Konsequenzen für Resourcen.
*/
export interface CitizenChoice {
label: string
description?: string
/** Wird ausgeführt, wenn der Spieler diese Option wählt */
apply: (game: GameEngine) => void
}
export interface CitizenEvent {
id: string
/** Welcher Bürger meldet sich? Emoji für Avatar */
character: string
/** Kurztitel, z.B. „Forderung der Fischer" */
title: string
/** Sprechblase / Forderung */
message: string
/** 23 Optionen */
choices: CitizenChoice[]
}
export interface GameSnapshot {
state: GameState
speed: GameSpeed
tick: number
tutorialStep: number
resources: Record<string, number>
variables: Record<string, number>
events: GameEvent[]
timeline: TimelineEntry[]
goals: Array<{ id: string; achieved: boolean; progress: number }>
}
/**
* Basisklasse für ein spielbares GeoGraSim-Modul
*/
export abstract class GameEngine {
readonly meta: GameMeta
protected state: GameState = 'tutorial'
protected speed: GameSpeed = 1
protected tick = 0
protected tutorialStep = 0
protected resources: Map<string, Resource> = new Map()
protected variables: Record<string, number> = {}
protected events: GameEvent[] = []
protected timeline: TimelineEntry[] = []
protected goals: GoalDef[] = []
protected unlockedFeatures: Set<string> = new Set()
protected tutorialSteps: TutorialStep[] = []
/** Aktuell anstehende Bürger-Beschwerde, bis sie der Spieler beantwortet hat */
protected pendingCitizenEvent: CitizenEvent | null = null
private animFrame = 0
private lastTickTime = 0
private listeners: Set<() => void> = new Set()
constructor(meta: GameMeta) {
this.meta = meta
}
// ==========================================================
// Setup (subclass calls these in constructor)
// ==========================================================
protected addResource(r: Omit<Resource, 'current'> & { current?: number }): void {
this.resources.set(r.id, { ...r, current: r.current ?? r.initial })
}
protected addGoal(g: GoalDef): void {
this.goals.push(g)
}
protected setTutorial(steps: TutorialStep[]): void {
this.tutorialSteps = steps
}
protected setVariable(name: string, value: number): void {
this.variables[name] = value
this.notify()
}
// ==========================================================
// Public API
// ==========================================================
getResource(id: string): number {
return this.resources.get(id)?.current ?? 0
}
setResource(id: string, value: number): void {
const r = this.resources.get(id)
if (!r) return
r.current = Math.max(r.min ?? -Infinity, Math.min(r.max ?? Infinity, value))
this.notify()
}
changeResource(id: string, delta: number): void {
this.setResource(id, this.getResource(id) + delta)
}
getVariable(name: string): number {
return this.variables[name] ?? 0
}
/** Spieler ändert eine Variable über UI (Slider, Button) */
playerSetVariable(name: string, value: number): void {
this.variables[name] = value
this.onPlayerAction(name, value)
this.notify()
}
// ==========================================================
// Bürger-Beschwerden
// ==========================================================
/** Subclass kann ein Citizen-Event in die Warteschlange legen */
protected triggerCitizenEvent(event: CitizenEvent): void {
if (this.pendingCitizenEvent) return // nur eines auf einmal
this.pendingCitizenEvent = event
this.notify()
}
/** Aktuell anstehende Bürger-Beschwerde (oder null) */
getPendingCitizenEvent(): CitizenEvent | null {
return this.pendingCitizenEvent
}
/** Spieler hat eine Wahl getroffen */
resolveCitizenEvent(choiceIndex: number): void {
const ev = this.pendingCitizenEvent
if (!ev) return
const choice = ev.choices[choiceIndex]
if (!choice) return
choice.apply(this)
this.addEvent(ev.id, `💬 ${ev.title}: „${choice.label}"`, 'info')
this.pendingCitizenEvent = null
this.notify()
}
// ==========================================================
// Game loop
// ==========================================================
start(): void {
if (this.state === 'tutorial' && this.tutorialSteps.length > 0) {
// Im Tutorial bleiben, manuell weiter
this.notify()
return
}
this.state = 'playing'
this.lastTickTime = performance.now()
this.loop()
}
pause(): void {
if (this.state === 'playing') {
this.state = 'paused'
cancelAnimationFrame(this.animFrame)
this.notify()
}
}
resume(): void {
if (this.state === 'paused') {
this.state = 'playing'
this.lastTickTime = performance.now()
this.loop()
}
}
setSpeed(s: GameSpeed): void {
this.speed = s
if (s === 0) this.pause()
else if (this.state === 'paused' || this.state === 'tutorial') {
this.state = 'playing'
this.lastTickTime = performance.now()
this.loop()
}
this.notify()
}
/** Vorzeitig abschließen — speichert finalen Zustand */
finish(): void {
cancelAnimationFrame(this.animFrame)
if (this.state !== 'won' && this.state !== 'lost') {
this.state = 'complete'
}
this.notify()
}
/** Tutorial einen Schritt weiter */
nextTutorialStep(): void {
this.tutorialStep++
if (this.tutorialStep >= this.tutorialSteps.length) {
// Tutorial fertig — Spiel beginnt
this.state = 'playing'
this.lastTickTime = performance.now()
this.loop()
} else {
const step = this.tutorialSteps[this.tutorialStep]
if (step.unlocks) step.unlocks.forEach(u => this.unlockedFeatures.add(u))
}
this.notify()
}
isFeatureUnlocked(name: string): boolean {
return this.unlockedFeatures.has(name)
}
// ==========================================================
// Save / Resume
// ==========================================================
/** Save-Format-Version. Bei Breaking Changes erhöhen — ältere saves werden dann verworfen. */
static readonly SAVE_VERSION = 2
serialize(): string {
return JSON.stringify({
v: GameEngine.SAVE_VERSION,
meta: this.meta.id,
state: this.state,
tick: this.tick,
tutorialStep: this.tutorialStep,
speed: this.speed,
variables: this.variables,
resources: Object.fromEntries(
Array.from(this.resources.entries()).map(([k, v]) => [k, v.current])
),
events: this.events,
timeline: this.timeline,
unlockedFeatures: Array.from(this.unlockedFeatures),
// Subclass-spezifischer Zustand (interne private Felder)
sub: this.serializeSubclass(),
})
}
deserialize(json: string): boolean {
try {
const data = JSON.parse(json)
if (data.meta !== this.meta.id) return false
// Alte/inkompatible saves komplett verwerfen
if (!data.v || data.v < GameEngine.SAVE_VERSION) return false
this.state = data.state
this.tick = data.tick
this.tutorialStep = data.tutorialStep
this.speed = data.speed
this.variables = data.variables
for (const [k, v] of Object.entries(data.resources as Record<string, number>)) {
const r = this.resources.get(k)
if (r) r.current = v
}
this.events = data.events
this.timeline = data.timeline
this.unlockedFeatures = new Set(data.unlockedFeatures)
// Subclass-spezifische Felder zurücksetzen
if (data.sub) this.deserializeSubclass(data.sub)
this.notify()
return true
} catch {
return false
}
}
/** Override in Subklasse, um zusätzliche interne Felder zu serialisieren. */
protected serializeSubclass(): Record<string, unknown> {
return {}
}
/** Override in Subklasse, um zusätzliche interne Felder wiederherzustellen. */
protected deserializeSubclass(_data: Record<string, unknown>): void {
// default: nichts
}
// ==========================================================
// Snapshot for UI
// ==========================================================
getSnapshot(): GameSnapshot {
return {
state: this.state,
speed: this.speed,
tick: this.tick,
tutorialStep: this.tutorialStep,
resources: Object.fromEntries(
Array.from(this.resources.entries()).map(([k, r]) => [k, r.current])
),
variables: { ...this.variables },
events: [...this.events],
timeline: [...this.timeline],
goals: this.goals.map(g => ({
id: g.id,
achieved: g.check(this),
progress: g.progress?.(this) ?? (g.check(this) ? 100 : 0),
})),
}
}
getResourcesArray(): Resource[] {
return Array.from(this.resources.values())
}
getCurrentTutorialStep(): TutorialStep | null {
return this.tutorialSteps[this.tutorialStep] || null
}
getEvents(limit = 5): GameEvent[] {
return this.events.slice(-limit).reverse()
}
// ==========================================================
// Subscribe (UI-Updates)
// ==========================================================
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
protected notify(): void {
this.listeners.forEach(fn => fn())
}
// ==========================================================
// Main loop (internal)
// ==========================================================
private loop = (): void => {
if (this.state !== 'playing') return
const now = performance.now()
const dt = now - this.lastTickTime
const interval = this.meta.msPerTick / this.speed
if (dt >= interval) {
this.lastTickTime = now
this.runTick()
}
this.animFrame = requestAnimationFrame(this.loop)
}
private runTick(): void {
this.tick++
// Subclass simulates one tick
this.simulateTick()
// Record timeline
this.recordTimeline()
// Check tutorial triggers
this.checkTutorialTriggers()
// Check win/loss
this.checkEndConditions()
// Max ticks
if (this.meta.maxTicks > 0 && this.tick >= this.meta.maxTicks) {
if (this.state !== 'won' && this.state !== 'lost') {
this.checkEndConditions() // Final check
if (this.state === 'playing') this.state = 'complete'
}
}
this.notify()
}
protected addEvent(
type: string,
text: string,
severity: GameEvent['severity'] = 'info',
infoKey?: string,
): void {
this.events.push({ tick: this.tick, type, text, severity, infoKey })
if (this.events.length > 50) this.events.shift()
}
private recordTimeline(): void {
const values: Record<string, number> = {}
for (const [k, r] of this.resources) values[k] = r.current
for (const [k, v] of Object.entries(this.variables)) values[k] = v
this.timeline.push({ tick: this.tick, values })
// Cap timeline length
if (this.timeline.length > 500) this.timeline.shift()
}
private checkTutorialTriggers(): void {
// Skip — tutorial is manual progression
}
private checkEndConditions(): void {
const requiredGoals = this.goals.filter(g => g.required)
const allRequired = requiredGoals.every(g => g.check(this))
if (allRequired && requiredGoals.length > 0) {
this.state = 'won'
this.addEvent('win', 'Du hast alle Hauptziele erreicht!', 'success')
cancelAnimationFrame(this.animFrame)
}
// Subclass can override loss conditions via checkLossCondition
if (this.checkLossCondition()) {
this.state = 'lost'
cancelAnimationFrame(this.animFrame)
}
}
// ==========================================================
// Subclass hooks
// ==========================================================
/** Wird bei jedem Tick aufgerufen — Subklasse simuliert hier ein Zeitintervall */
protected abstract simulateTick(): void
/** Reagiere auf Spieleraktion (z.B. Slider-Bewegung, Bau-Klick) */
protected onPlayerAction(_name: string, _value: number): void {}
/** Soll das Spiel verloren sein? Override in Subklasse */
protected checkLossCondition(): boolean {
return false
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* GeoGraSim — Persistence Layer
*
* Dualer Speicher: localStorage (immer, schnell, offline) + PHP-API (wenn Session vorhanden).
* Wird von game-ui.ts und den Simulationen verwendet.
*/
interface GGSContext {
sessionId: string | null
teacherId: number | null
baseUrl: string
basePath: string
}
function getContext(): GGSContext {
return (window as any).__GGS__ ?? { sessionId: null, teacherId: null, baseUrl: '', basePath: '' }
}
function hasSession(): boolean {
return !!getContext().sessionId
}
function apiUrl(path: string): string {
return getContext().baseUrl + '/api/' + path
}
export const persistence = {
/**
* Spielstand speichern.
* Schreibt IMMER in localStorage (schnell + offline).
* Spiegelt an PHP-API wenn eine Session existiert (fire-and-forget).
*/
async save(key: string, data: string, version = 2): Promise<void> {
// 1. Immer localStorage (synchron, schnell)
try { localStorage.setItem(key, data) } catch { /* quota exceeded */ }
// 2. Spiegeln an Server wenn Session vorhanden
if (!hasSession()) return
try {
await fetch(apiUrl('saves'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ key, data, version }),
})
} catch { /* Netzwerk offline — localStorage hat den Save */ }
},
/**
* Spielstand laden.
* Bevorzugt Server (aktueller ueber Geraete hinweg), Fallback auf localStorage.
*/
async load(key: string): Promise<string | null> {
if (hasSession()) {
try {
const res = await fetch(apiUrl('saves') + '?key=' + encodeURIComponent(key), {
credentials: 'same-origin',
})
const json = await res.json()
if (json.data) return json.data
} catch { /* Fallback auf localStorage */ }
}
return localStorage.getItem(key)
},
/**
* Assessment-Daten an den Server senden (fuer Lehrkraefte-Dashboard).
*/
async submitAssessment(simId: string, data: Record<string, unknown>): Promise<void> {
if (!hasSession()) return
try {
await fetch(apiUrl('assessment'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ simId, ...data }),
})
} catch { /* best-effort */ }
},
/** Session-Status abfragen */
async getSessionStatus(): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(apiUrl('sessions'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'status' }),
})
return await res.json()
} catch { return null }
},
/** Einer Klasse beitreten */
async joinClass(joinCode: string, displayName: string): Promise<Record<string, unknown>> {
const res = await fetch(apiUrl('sessions'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'join', joinCode, displayName }),
})
return await res.json()
},
/** Pruefen ob Session existiert (synchron, liest nur window.__GGS__) */
hasSession,
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Minimaler Hash-Router
* Kein Framework nötig — einfaches Hash-basiertes Routing.
*
* Routen:
* #/ → Landing Page
* #/sim/05 → Simulation 05 (Treibhauseffekt)
* #/sim/07 → Simulation 07 (Erdbeben)
* #/dashboard → Lehrkräfte-Dashboard (später)
*/
type RouteHandler = (params: Record<string, string>) => void
interface Route {
pattern: RegExp
handler: RouteHandler
}
export class Router {
private routes: Route[] = []
private currentCleanup: (() => void) | null = null
constructor() {
window.addEventListener('hashchange', () => this.resolve())
window.addEventListener('load', () => this.resolve())
}
on(path: string, handler: RouteHandler): this {
// Convert path like '/sim/:id' to regex
const pattern = new RegExp(
'^' + path.replace(/:[a-zA-Z]+/g, '([^/]+)') + '$'
)
this.routes.push({ pattern, handler })
return this
}
navigate(path: string): void {
window.location.hash = path
}
resolve(): void {
const hash = window.location.hash.slice(1) || '/'
for (const route of this.routes) {
const match = hash.match(route.pattern)
if (match) {
// Cleanup previous view
if (this.currentCleanup) {
this.currentCleanup()
this.currentCleanup = null
}
// Extract params
const params: Record<string, string> = {}
const paramNames = route.pattern.source.match(/\([^)]+\)/g) || []
paramNames.forEach((_, i) => {
params[`p${i}`] = match[i + 1]
})
route.handler(params)
return
}
}
}
setCleanup(fn: () => void): void {
this.currentCleanup = fn
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Simulation Base Class
*
* Jede Simulation erbt von dieser Klasse.
* Trennung: Logik (testbar ohne Canvas) ↔ Rendering (Canvas)
*/
export interface SimulationMeta {
id: string // z.B. "sim-05"
name: string // z.B. "Treibhauseffekt-Simulator"
/**
* Unterstützte Schulstufen (internationale Nummerierung, 113)
* z.B. [5, 6] = 1.+2. Klasse Mittelschule in AT, Klasse 5+6 in DE
* Die länderspezifische Bezeichnung wird über education-levels.ts aufgelöst.
*/
educationLevels: number[]
/**
* Primäre Zielstufe (für Sortierung und Empfehlung)
* z.B. 5 = 1. Klasse Mittelschule in AT
*/
primaryLevel: number
kompetenzbereich: string
lernziele: string[]
basiskonzepte: string[]
dpiMinuten: number // erwartete Dauer in Minuten
typ: 'sachsimulation' | 'planspiel' | 'exploration' | 'rollenspiel' | 'abstimmung'
tier: 1 | 2 | 3
/**
* Braucht die Simulation Lesekompetenz?
* false = auch für Grundstufe 1 nutzbar (rein visuell/auditiv)
*/
requiresReading: boolean
}
export interface SimulationState {
phase: 'intro' | 'predict' | 'simulate' | 'observe' | 'reflect' | 'complete'
startTime: number
elapsedMs: number
variables: Record<string, number>
predictions: Record<string, unknown>
results: Record<string, unknown>
reflections: string[]
}
export interface AssessmentData {
processLog: Array<{
timestamp: number
action: string
variable?: string
oldValue?: number
newValue?: number
}>
predictions: Record<string, unknown>
results: Record<string, unknown>
reflections: string[]
duration: number
completedPhases: string[]
}
export abstract class Simulation {
readonly meta: SimulationMeta
protected state: SimulationState
constructor(meta: SimulationMeta) {
this.meta = meta
this.state = {
phase: 'intro',
startTime: Date.now(),
elapsedMs: 0,
variables: {},
predictions: {},
results: {},
reflections: [],
}
this._processLog = [] // Sicherstellen, dass das Array existiert, bevor Subklassen Variablen setzen
}
/** Setzt eine Variable und loggt die Änderung */
setVariable(name: string, value: number): void {
const old = this.state.variables[name]
this.state.variables[name] = value
this.logAction('set-variable', name, old, value)
this.onVariableChange(name, value)
}
/** Holt den aktuellen Wert einer Variable */
getVariable(name: string): number {
return this.state.variables[name] ?? 0
}
/** Speichert eine Vorhersage (Predict-Phase) */
setPrediction(key: string, value: unknown): void {
this.state.predictions[key] = value
this.logAction('predict', key)
}
/** Speichert eine Reflexion (Reflect-Phase) */
addReflection(text: string): void {
this.state.reflections.push(text)
this.logAction('reflect')
}
/** Wechselt zur nächsten Phase */
nextPhase(): void {
const phases: SimulationState['phase'][] = ['intro', 'predict', 'simulate', 'observe', 'reflect', 'complete']
const idx = phases.indexOf(this.state.phase)
if (idx < phases.length - 1) {
this.state.phase = phases[idx + 1]
this.logAction('phase-change')
}
}
/** Gibt Assessment-Daten für das Lehrkräfte-Dashboard */
getAssessmentData(): AssessmentData {
return {
processLog: [...this._processLog],
predictions: { ...this.state.predictions },
results: { ...this.state.results },
reflections: [...this.state.reflections],
duration: Date.now() - this.state.startTime,
completedPhases: this.getCompletedPhases(),
}
}
// --- Abstrakte Methoden — jede Simulation implementiert diese ---
/** Berechne den aktuellen Zustand basierend auf Variablen */
abstract compute(): Record<string, number>
/** Reagiere auf Variablenänderung */
protected abstract onVariableChange(name: string, value: number): void
/** Gib die initialen Variablen und ihre Bereiche zurück */
abstract getVariableRanges(): Record<string, { min: number; max: number; default: number; unit: string; label: string }>
// --- Internes Logging ---
private _processLog!: AssessmentData['processLog']
private logAction(action: string, variable?: string, oldValue?: number, newValue?: number): void {
this._processLog.push({
timestamp: Date.now() - this.state.startTime,
action,
variable,
oldValue,
newValue,
})
}
private getCompletedPhases(): string[] {
const all: SimulationState['phase'][] = ['intro', 'predict', 'simulate', 'observe', 'reflect', 'complete']
const idx = all.indexOf(this.state.phase)
return all.slice(0, idx + 1)
}
}
+11
View File
@@ -0,0 +1,11 @@
import './styles/base.css'
import { ScenicBackground } from './ui/animations/scenic-bg'
// Scenic Background starten
const heroSection = document.getElementById('hero')
if (heroSection) {
const scenic = new ScenicBackground(heroSection)
scenic.start()
}
console.log('🌍 GeoGraSim geladen')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,806 @@
/**
* Klimawächter — Canvas Renderer (V3)
*
* Designprinzipien (nach User-Feedback):
* - RUHE: Kein Tag-Nacht-Wechsel, immer Tagslicht
* - DEZENT: Jahreszeiten als sehr leichte Farbverschiebung, keine Vollbild-Effekte
* - KLIMASTRESS sichtbar: Mit steigender Temperatur wird Himmel gelblicher,
* Land trockener — das ist die einzige langfristige Farbveränderung
* - DETERMINISTISCHE BAUTEN: Position fest beim Bauen
* - ZEITLEISTE am unteren Rand: 2025 ━━●━━━ 2100
* - LEBEN am Rand: Möwen, Hintergrundschiffe, springender Fisch
*
* Zeitraum: 20252100 (75 Jahre, 75 Ticks)
*/
import { KlimawaechterGame } from './game'
interface PlacedObject {
type: 'tree' | 'solar' | 'wind' | 'green-roof' | 'dike' | 'sea-wall'
x: number // 0..1
scale: number
ownerId: string
builtTick: number
}
interface Bird {
x: number
y: number
vx: number
wingPhase: number
size: number
}
interface BgShip {
x: number
speed: number
size: number
}
export class KlimawaechterRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private game: KlimawaechterGame
private W = 0
private H = 0
private t = 0
private animId = 0
private placed: PlacedObject[] = []
private knownIds = new Set<string>()
private lastTick = -1
private tickStartT = 0
private birds: Bird[] = []
private bgShips: BgShip[] = []
private fishTimer = 0
private fishX = 0
private fishPhase = -1
private houseSeeds: number[] = []
private landSurfaceFn: (x: number) => number = () => 0
constructor(container: HTMLElement, game: KlimawaechterGame) {
this.game = game
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dde3da;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
this.initFauna()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.55, 420)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
private initFauna(): void {
if (this.houseSeeds.length === 0) {
for (let i = 0; i < 12; i++) {
this.houseSeeds.push(this.seededRand(i * 7919) * 0.04 - 0.02)
}
}
for (let i = 0; i < 4; i++) {
this.birds.push({
x: Math.random() * this.W,
y: this.H * (0.05 + Math.random() * 0.18),
vx: 0.15 + Math.random() * 0.2,
wingPhase: Math.random() * Math.PI * 2,
size: 3 + Math.random() * 3,
})
}
this.bgShips = [
{ x: this.W * 0.1, speed: 0.05, size: 0.7 },
{ x: this.W * 0.6, speed: 0.03, size: 0.5 },
]
}
private seededRand(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453
return x - Math.floor(x)
}
start(): void {
let lastFrame = performance.now()
const loop = (now: number) => {
const dt = (now - lastFrame) / 1000
lastFrame = now
this.t += dt
this.updateScene()
this.draw()
this.animId = requestAnimationFrame(loop)
}
this.animId = requestAnimationFrame(loop)
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private updateScene(): void {
const snap = this.game.getSnapshot()
if (snap.tick !== this.lastTick) {
this.lastTick = snap.tick
this.tickStartT = this.t
}
const owned = this.game.getOwnedMeasures()
for (const m of owned) {
for (let i = 0; i < m.count; i++) {
const id = `${m.measureId}-${i}`
if (!this.knownIds.has(id)) {
this.knownIds.add(id)
this.placed.push(this.placeMeasure(m.measureId, i, snap.tick))
}
}
}
for (const b of this.birds) {
b.x += b.vx
b.wingPhase += 0.18
b.y += Math.sin(this.t * 0.6 + b.wingPhase * 0.3) * 0.1
if (b.x > this.W + 30) {
b.x = -30
b.y = this.H * (0.05 + Math.random() * 0.18)
}
}
for (const s of this.bgShips) {
s.x += s.speed
if (s.x > this.W + 80) s.x = -80
}
}
private placeMeasure(id: string, index: number, tick: number): PlacedObject {
const baseScale = 0.85 + this.seededRand(index * 991 + 31) * 0.3
const stableId = `${id}-${index}`
if (id === 'forest') {
const slot = index % 6
const x = 0.04 + slot * 0.025 + this.seededRand(index * 13 + 7) * 0.015
return { type: 'tree', x, scale: baseScale, ownerId: stableId, builtTick: tick }
}
if (id === 'solar') {
const slot = index % 5
const x = 0.78 - slot * 0.035 + this.seededRand(index * 17 + 3) * 0.01
return { type: 'solar', x, scale: 0.95, ownerId: stableId, builtTick: tick }
}
if (id === 'wind') {
const slot = index % 4
const x = 0.02 + slot * 0.04 + this.seededRand(index * 23 + 11) * 0.01
return { type: 'wind', x, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'green-roof') {
return { type: 'green-roof', x: 0, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'dike') {
const slot = index % 4
const x = 0.83 + slot * 0.035
return { type: 'dike', x, scale: 1, ownerId: stableId, builtTick: tick }
}
if (id === 'sea-wall') {
const x = 0.95
return { type: 'sea-wall', x, scale: 1 + index * 0.08, ownerId: stableId, builtTick: tick }
}
return { type: 'tree', x: 0.5, scale: 1, ownerId: stableId, builtTick: tick }
}
// ============================================================
// RENDERING
// ============================================================
private draw(): void {
const { ctx, W, H } = this
ctx.clearRect(0, 0, W, H)
const snap = this.game.getSnapshot()
const co2 = snap.resources.co2 ?? 425
const temp = snap.resources.temperature ?? 15
const seaCm = snap.resources.sealevel ?? 0
const flooded = snap.resources.flooded ?? 0
const speed = snap.speed || 1
// Klimastress: subtiler Farbwandel über die Zeit
// Temperatur 15 → 19+ → Himmel wird gelblicher, Land trockener
const tempStress = Math.max(0, Math.min(1, (temp - 15) / 4))
const co2Stress = Math.max(0, Math.min(1, (co2 - 400) / 400))
// Saison-Fortschritt innerhalb des aktuellen Jahres (für sehr dezente Akzente)
const msPerTick = 4000 / Math.max(0.001, speed)
const elapsedInTick = (this.t - this.tickStartT) * 1000
const tickProgress = Math.min(1, elapsedInTick / msPerTick)
const seasonF = tickProgress * 4
const seasonIdx = Math.floor(seasonF) % 4
const seasonBlend = seasonF - Math.floor(seasonF)
// Reservierter Bereich für die Zeitleiste am unteren Rand
const timelineH = 28
const sceneH = H - timelineH
const groundY = sceneH * 0.7
const baseSeaY = sceneH * 0.78
const seaY = baseSeaY - Math.min(baseSeaY * 0.2, seaCm * 0.4)
// ===== HIMMEL — basiert auf Klimastress, NICHT auf Tageszeit =====
const skyTopBase = [200, 215, 210] // hell-bläulich
const skyMidBase = [215, 225, 215]
const skyBottomBase = [225, 230, 215]
// Klimastress: Himmel wird gelb-bräunlicher
const stressedSky = (base: number[]) => {
const r = Math.round(base[0] + tempStress * 25 + co2Stress * 10)
const g = Math.round(base[1] + tempStress * 5 - co2Stress * 5)
const b = Math.round(base[2] - tempStress * 30 - co2Stress * 25)
return `rgb(${r},${g},${b})`
}
const sky = ctx.createLinearGradient(0, 0, 0, groundY)
sky.addColorStop(0, stressedSky(skyTopBase))
sky.addColorStop(0.6, stressedSky(skyMidBase))
sky.addColorStop(1, stressedSky(skyBottomBase))
ctx.fillStyle = sky
ctx.fillRect(0, 0, W, groundY)
// ===== SONNE — fix oben rechts, dezent =====
const sunX = W * 0.85
const sunY = sceneH * 0.13
const sunGlow = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 60)
sunGlow.addColorStop(0, 'rgba(255,235,180,0.35)')
sunGlow.addColorStop(1, 'rgba(255,235,180,0)')
ctx.fillStyle = sunGlow
ctx.fillRect(sunX - 60, sunY - 60, 120, 120)
// Sonnen-Farbe wird leicht orange-rot bei Klimastress
const sunR = 240 + tempStress * 15
const sunG = 200 - tempStress * 30
const sunB = 100 - tempStress * 30
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
ctx.beginPath()
ctx.arc(sunX, sunY, 18, 0, Math.PI * 2)
ctx.fill()
// ===== WOLKEN — dezent driftend =====
const cloudOffset = this.t * 4
for (let i = 0; i < 4; i++) {
const baseX = (i / 4) * W * 1.4 - W * 0.1
const cx = ((baseX + cloudOffset * (1 + i * 0.1)) % (W + 100)) - 50
const cy = sceneH * (0.08 + i * 0.04)
const cloudOpacity = 0.7 - co2Stress * 0.2
this.drawCloud(ctx, cx, cy, 0.7 + (i % 3) * 0.15, `rgba(255,255,255,${cloudOpacity})`)
}
// ===== KEIL-INSEL — schräg ansteigend von rechts (Meer) nach links (Vulkan) =====
// landSurface(x) = y-Koordinate der Landoberfläche an horizontaler Position x
// Hinten/links hoch, vorne/rechts niedrig (sinkt knapp unter baseSeaY)
const landHighY = groundY - 40 // linke Seite (hinter Vulkan)
const landLowY = baseSeaY - 6 // rechte Seite — knapp über Wasser
const landSurface = (x: number): number => {
const t = x / W // 0 links → 1 rechts
// Leicht quadratisch abfallend für natürlicheren Keil
const ease = t * t * 0.4 + t * 0.6
const base = landHighY * (1 - ease) + landLowY * ease
// Kleine Welligkeit
return base + Math.sin(x * 0.02) * 3 + Math.sin(x * 0.006 + 1.2) * 4
}
// Speicher als Render-State für Maßnahmen-Platzierung
this.landSurfaceFn = landSurface
// Hintergrund-Berge (weit links, entsprechen weiterem Hinterland)
const mountainR = 150 + tempStress * 25
const mountainG = 165 - tempStress * 30
const mountainB = 150 - tempStress * 35
ctx.fillStyle = `rgb(${mountainR},${mountainG},${mountainB})`
ctx.beginPath()
ctx.moveTo(0, landSurface(0) - 20)
for (let x = 0; x <= W * 0.6; x += 25) {
const my = landSurface(x) - 30 - Math.sin(x * 0.007 + 1) * 18 - Math.sin(x * 0.014 + 0.3) * 10
ctx.lineTo(x, my)
}
ctx.lineTo(W * 0.6, landSurface(W * 0.6))
ctx.lineTo(0, landSurface(0))
ctx.closePath()
ctx.fill()
// === VULKAN mit Gletscher-Deckel (links, ragt aus der Landschaft) ===
const volcanoBaseX = W * 0.16
const volcanoBaseY = landSurface(volcanoBaseX) - 2
const volcanoHeight = 120
const volcanoHalfBase = 55
const volcanoTopHalf = 14
// Fels
ctx.fillStyle = `rgb(${106 + tempStress * 20},${90 + tempStress * 10},${74})`
ctx.beginPath()
ctx.moveTo(volcanoBaseX - volcanoHalfBase, volcanoBaseY)
ctx.lineTo(volcanoBaseX - volcanoTopHalf, volcanoBaseY - volcanoHeight)
ctx.lineTo(volcanoBaseX + volcanoTopHalf, volcanoBaseY - volcanoHeight)
ctx.lineTo(volcanoBaseX + volcanoHalfBase, volcanoBaseY)
ctx.closePath()
ctx.fill()
// Krater-Delle
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath()
ctx.ellipse(volcanoBaseX, volcanoBaseY - volcanoHeight + 1, volcanoTopHalf - 2, 2, 0, 0, Math.PI * 2)
ctx.fill()
// Gletscher-Deckel (1/3 der Höhe, opak, schrumpft mit Temperatur)
const glacierFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3))
if (glacierFraction > 0.02) {
const glacierFullH = volcanoHeight / 3
const glacierH = glacierFullH * glacierFraction
const glacierBaseY = volcanoBaseY - (volcanoHeight - glacierFullH + glacierFullH - glacierH)
const grime = (1 - glacierFraction) * 40
// Die Deckel-Form ist ein Trapez, breiter als der Vulkan-Top
const capBottomHalf = (volcanoTopHalf + 6) * (0.5 + glacierFraction * 0.5)
const capTopHalf = volcanoTopHalf * (0.6 + glacierFraction * 0.4)
ctx.fillStyle = `rgb(${240 - grime},${246 - grime},${250 - grime})`
ctx.beginPath()
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
ctx.lineTo(volcanoBaseX - capTopHalf, glacierBaseY - glacierH)
ctx.lineTo(volcanoBaseX + capTopHalf, glacierBaseY - glacierH)
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
ctx.closePath()
ctx.fill()
// Schatten-Linie unten am Deckel
ctx.strokeStyle = `rgba(140,150,160,${0.3 * glacierFraction})`
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
ctx.stroke()
}
// ===== LAND (Keil) — Grasfarbe verändert sich mit Klimastress =====
const grassR = 140 + tempStress * 30
const grassG = 165 - tempStress * 35
const grassB = 110 - tempStress * 25
ctx.fillStyle = `rgb(${grassR},${grassG},${grassB})`
ctx.beginPath()
ctx.moveTo(0, landSurface(0))
for (let x = 0; x <= W; x += 10) {
ctx.lineTo(x, landSurface(x))
}
// Rechte untere Ecke: in den Boden / unter die Wasserlinie
ctx.lineTo(W, sceneH)
ctx.lineTo(0, sceneH)
ctx.closePath()
ctx.fill()
// Dünner Bodenstreifen direkt unter dem Gras
ctx.fillStyle = `rgb(${110 + tempStress * 20},${100 - tempStress * 15},${80 - tempStress * 20})`
for (let x = 0; x <= W; x += 4) {
ctx.fillRect(x, landSurface(x) + 4, 4, 6)
}
// ===== BÄUME =====
for (const obj of this.placed.filter(p => p.type === 'tree')) {
const px = obj.x * W
this.drawTree(ctx, px, landSurface(px) - 1, obj.scale, tempStress)
}
// ===== WINDRÄDER (stehen auf dem Keil — weiter oben links ist windiger) =====
for (const obj of this.placed.filter(p => p.type === 'wind')) {
const px = obj.x * W
this.drawWind(ctx, px, landSurface(px) - 5, this.t)
}
// ===== HÄUSER — bevorzugt auf der höheren (linken) Seite =====
const greenRoofs = this.placed.filter(s => s.type === 'green-roof').length
const totalHouses = 12
const floodedCount = Math.round((flooded / 100) * totalHouses)
for (let i = 0; i < totalHouses; i++) {
// Häuser eher im Mittelteil/links (0.28..0.72) — Tiefland rechts bleibt leer
const x = (0.28 + (i / totalHouses) * 0.44 + this.houseSeeds[i] * 0.8) * W
const isFlooded = i >= (totalHouses - floodedCount)
const hasGreenRoof = i < greenRoofs
this.drawHouse(ctx, x, landSurface(x) - 1, 0.95 + this.houseSeeds[i] * 4, isFlooded, hasGreenRoof)
}
// ===== SOLARANLAGEN =====
for (const obj of this.placed.filter(p => p.type === 'solar')) {
const px = obj.x * W
this.drawSolar(ctx, px, landSurface(px) - 4)
}
// ===== MEER =====
const seaR = 145 - tempStress * 10
const seaG = 175 - tempStress * 15
const seaB = 175 - tempStress * 5
const seaGrad = ctx.createLinearGradient(0, seaY, 0, sceneH)
seaGrad.addColorStop(0, `rgb(${seaR},${seaG},${seaB})`)
seaGrad.addColorStop(1, `rgb(${seaR - 25},${seaG - 25},${seaB - 15})`)
ctx.fillStyle = seaGrad
ctx.fillRect(0, seaY, W, sceneH - seaY)
// Hintergrundschiffe
for (const ship of this.bgShips) {
this.drawBgShip(ctx, ship.x, seaY - 4, ship.size)
}
// Wellen
for (let i = 0; i < 4; i++) {
ctx.beginPath()
ctx.moveTo(0, seaY)
for (let x = 0; x <= W; x += 4) {
ctx.lineTo(x, seaY + Math.sin(x * 0.02 + this.t * (1 + i * 0.5)) * (1.2 + i))
}
ctx.lineTo(W, sceneH); ctx.lineTo(0, sceneH); ctx.closePath()
ctx.fillStyle = `rgba(255,255,255,${0.04 - i * 0.008})`
ctx.fill()
}
// Sonnenreflexion auf dem Wasser (immer, da Sonne fix oben)
ctx.globalAlpha = 0.25
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
for (let i = 0; i < 5; i++) {
const ry = seaY + 4 + i * 4
const rw = 24 - i * 3 + Math.sin(this.t * 2 + i) * 3
ctx.fillRect(sunX - rw / 2, ry, rw, 1)
}
ctx.globalAlpha = 1
// ===== DEICHE & MAUERN =====
for (const obj of this.placed.filter(p => p.type === 'dike')) {
this.drawDike(ctx, obj.x * W, groundY)
}
for (const obj of this.placed.filter(p => p.type === 'sea-wall')) {
this.drawSeaWall(ctx, obj.x * W, groundY - 18, baseSeaY)
}
// ===== BEWOHNER =====
const popLossPct = Math.max(0, 10000 - snap.resources.population) / 10000
const peopleCount = Math.round(8 * (1 - popLossPct))
for (let i = 0; i < peopleCount; i++) {
const px = W * (0.22 + (i / 8) * 0.58 + Math.sin(this.t * 0.5 + i) * 0.003)
const py = groundY - 1
ctx.fillStyle = '#3a3a3a'
ctx.fillRect(px - 1, py - 5, 2, 5)
ctx.beginPath()
ctx.arc(px, py - 6, 1.4, 0, Math.PI * 2)
ctx.fill()
}
// ===== VÖGEL =====
for (const b of this.birds) {
this.drawBird(ctx, b)
}
// ===== SPRINGENDER FISCH =====
this.fishTimer -= 0.016
if (this.fishTimer <= 0) {
this.fishTimer = 8 + Math.random() * 12
this.fishX = W * (0.55 + Math.random() * 0.35)
this.fishPhase = 0
}
if (this.fishPhase >= 0 && this.fishPhase < 1) {
this.fishPhase += 0.018
const fy = seaY - Math.sin(this.fishPhase * Math.PI) * 18
const rot = -Math.PI * 0.3 + this.fishPhase * Math.PI * 0.6
ctx.save()
ctx.translate(this.fishX, fy)
ctx.rotate(rot)
ctx.globalAlpha = 0.5
ctx.fillStyle = '#5a7a7e'
ctx.beginPath()
ctx.ellipse(0, 0, 5, 2, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.moveTo(-5, 0); ctx.lineTo(-8, -2); ctx.lineTo(-8, 2); ctx.closePath()
ctx.fill()
ctx.restore()
ctx.globalAlpha = 1
}
// ===== DEZENTE SAISON-AKZENTE (sehr klein, nicht aufdringlich) =====
if (seasonIdx === 2) this.drawLeaves(ctx, seasonBlend, sceneH)
if (seasonIdx === 3) this.drawSnow(ctx, seasonBlend, sceneH)
// ===== ZEITLEISTE am unteren Rand =====
this.drawTimeline(ctx, timelineH, snap.tick, snap.events)
}
// ============================================================
// ZEITLEISTE
// ============================================================
private drawTimeline(ctx: CanvasRenderingContext2D, h: number, currentTick: number, events: any[]): void {
const { W, H } = this
const y = H - h
const marginX = 40
// Hintergrund
ctx.fillStyle = 'rgba(255,255,255,0.92)'
ctx.fillRect(0, y, W, h)
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, y); ctx.lineTo(W, y)
ctx.stroke()
// Zeitleiste
const lineY = y + h / 2 + 2
const lineX0 = marginX
const lineX1 = W - marginX
// Linie
ctx.strokeStyle = '#c8c4b8'
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(lineX1, lineY)
ctx.stroke()
// Dezimal-Markierungen (alle 10 Jahre)
const totalTicks = 75
for (let i = 0; i <= 7; i++) {
const decade = i * 10
const x = lineX0 + (decade / totalTicks) * (lineX1 - lineX0)
ctx.strokeStyle = '#a8a497'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, lineY - 3); ctx.lineTo(x, lineY + 3)
ctx.stroke()
// Jahreszahl
if (i === 0 || i === 7 || i % 2 === 0) {
ctx.fillStyle = '#7a7468'
ctx.font = '9px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${2025 + decade}`, x, lineY + 14)
}
}
// Aktueller Stand — runder Marker
const progress = Math.min(1, currentTick / totalTicks)
const markerX = lineX0 + progress * (lineX1 - lineX0)
// Track bis hierher leicht hervorheben
ctx.strokeStyle = '#4a7c8a'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(markerX, lineY)
ctx.stroke()
// Marker-Kreis
ctx.fillStyle = '#4a7c8a'
ctx.beginPath()
ctx.arc(markerX, lineY, 5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.arc(markerX, lineY, 2, 0, Math.PI * 2)
ctx.fill()
// Aktuelles Jahr über dem Marker
const currentYear = 2025 + currentTick
ctx.fillStyle = '#4a7c8a'
ctx.font = 'bold 10px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${currentYear}`, markerX, lineY - 8)
// Event-Marker auf der Linie (kleine Punkte)
for (const ev of events) {
if (ev.tick > currentTick) continue
const ex = lineX0 + (ev.tick / totalTicks) * (lineX1 - lineX0)
ctx.fillStyle = ev.severity === 'danger' ? '#c0503c' : ev.severity === 'warning' ? '#c4a35a' : ev.severity === 'success' ? '#5a8a5e' : '#8a8a8a'
ctx.beginPath()
ctx.arc(ex, lineY - 8, 2, 0, Math.PI * 2)
ctx.fill()
}
}
// ============================================================
// DRAW HELPERS
// ============================================================
private drawCloud(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string): void {
ctx.fillStyle = color
ctx.beginPath()
ctx.ellipse(x, y, 24*s, 9*s, 0, 0, Math.PI*2); ctx.fill()
ctx.beginPath()
ctx.ellipse(x-13*s, y+2*s, 17*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
ctx.beginPath()
ctx.ellipse(x+14*s, y+2*s, 18*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
}
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, stress: number): void {
// Bei Klimastress: Bäume werden bräunlicher
const leafG = 130 - stress * 40
const leafR = 90 + stress * 60
const leafB = 80 - stress * 30
const shadeG = 100 - stress * 35
const shadeR = 70 + stress * 55
const shadeB = 60 - stress * 25
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 1.5*s, y - 9*s, 3*s, 9*s)
ctx.fillStyle = `rgb(${shadeR},${shadeG},${shadeB})`
ctx.beginPath()
ctx.arc(x - 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x + 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = `rgb(${leafR},${leafG},${leafB})`
ctx.beginPath()
ctx.arc(x, y - 14*s, 7*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x - 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x + 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
ctx.fill()
}
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, flooded: boolean, greenRoof: boolean): void {
if (flooded) {
ctx.globalAlpha = 0.6
ctx.fillStyle = '#a08878'
ctx.fillRect(x - 7*s, y - 8*s, 14*s, 8*s)
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 7*s, y - 4*s, 14*s, 4*s)
ctx.globalAlpha = 1
return
}
// Wand
ctx.fillStyle = '#e8d8b8'
ctx.fillRect(x - 8*s, y - 14*s, 16*s, 14*s)
// Dach
if (greenRoof) {
ctx.fillStyle = '#6aa86e'
} else {
ctx.fillStyle = '#b06a5a'
}
ctx.beginPath()
ctx.moveTo(x - 10*s, y - 14*s)
ctx.lineTo(x, y - 22*s)
ctx.lineTo(x + 10*s, y - 14*s)
ctx.closePath()
ctx.fill()
// Fenster
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 3*s, y - 11*s, 6*s, 5*s)
// Tür
ctx.fillStyle = '#6a4a3a'
ctx.fillRect(x - 2*s, y - 5*s, 4*s, 5*s)
}
private drawSolar(ctx: CanvasRenderingContext2D, x: number, y: number): void {
const tilt = -0.3
ctx.save()
ctx.translate(x, y)
ctx.rotate(tilt)
ctx.fillStyle = 'rgba(255,220,150,0.3)'
ctx.fillRect(-10, -12, 20, 12)
ctx.fillStyle = '#3a4a6a'
ctx.fillRect(-8, -10, 16, 8)
ctx.strokeStyle = '#5a6a8a'
ctx.lineWidth = 0.5
for (let i = -6; i <= 6; i += 4) {
ctx.beginPath()
ctx.moveTo(i, -10); ctx.lineTo(i, -2)
ctx.stroke()
}
ctx.beginPath()
ctx.moveTo(-8, -6); ctx.lineTo(8, -6)
ctx.stroke()
ctx.restore()
ctx.fillStyle = '#5a5a5a'
ctx.fillRect(x - 1, y - 8, 2, 8)
}
private drawWind(ctx: CanvasRenderingContext2D, x: number, y: number, t: number): void {
ctx.fillStyle = '#e0dcd0'
ctx.fillRect(x - 1.5, y - 30, 3, 30)
ctx.beginPath()
ctx.arc(x, y - 30, 2.5, 0, Math.PI * 2)
ctx.fill()
const angle = t * 1.8
for (let i = 0; i < 3; i++) {
const a = angle + (i / 3) * Math.PI * 2
ctx.save()
ctx.translate(x, y - 30)
ctx.rotate(a)
ctx.fillStyle = '#f0ece0'
ctx.beginPath()
ctx.ellipse(0, -8, 1.2, 10, 0, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
private drawDike(ctx: CanvasRenderingContext2D, x: number, groundY: number): void {
ctx.fillStyle = '#8a7a6a'
ctx.beginPath()
ctx.moveTo(x - 12, groundY)
ctx.lineTo(x - 6, groundY - 14)
ctx.lineTo(x + 6, groundY - 14)
ctx.lineTo(x + 12, groundY)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#6a5a4a'
ctx.fillRect(x - 7, groundY - 16, 14, 2)
}
private drawSeaWall(ctx: CanvasRenderingContext2D, x: number, topY: number, baseY: number): void {
ctx.fillStyle = '#a8a8a8'
ctx.fillRect(x - 4, topY, 8, baseY - topY + 10)
ctx.fillStyle = '#888'
ctx.fillRect(x - 5, topY, 10, 3)
}
private drawBird(ctx: CanvasRenderingContext2D, b: Bird): void {
const wing = Math.sin(b.wingPhase) * 0.5
ctx.globalAlpha = 0.5
ctx.strokeStyle = '#3a3a3a'
ctx.lineWidth = 1.3
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(b.x - b.size, b.y - wing * b.size)
ctx.quadraticCurveTo(b.x, b.y + 1.5, b.x + b.size, b.y - wing * b.size)
ctx.stroke()
ctx.globalAlpha = 1
}
private drawBgShip(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
ctx.globalAlpha = 0.4
ctx.fillStyle = '#6a5a5a'
ctx.beginPath()
ctx.moveTo(x - 14 * s, y)
ctx.lineTo(x - 11 * s, y + 5 * s)
ctx.lineTo(x + 11 * s, y + 5 * s)
ctx.lineTo(x + 14 * s, y)
ctx.closePath()
ctx.fill()
ctx.strokeStyle = '#4a4a4a'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(x, y); ctx.lineTo(x, y - 14 * s)
ctx.stroke()
ctx.fillStyle = '#e8e3d8'
ctx.beginPath()
ctx.moveTo(x + 1, y - 13 * s)
ctx.lineTo(x + 9 * s, y - 2 * s)
ctx.lineTo(x + 1, y - 1 * s)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1
}
private drawLeaves(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
// Sehr dezent — nur 4 Blätter
ctx.globalAlpha = blend * 0.3
for (let i = 0; i < 4; i++) {
const lx = (i * 173 + this.t * 6) % this.W
const ly = ((this.t * 8 + i * 89) % (sceneH * 0.7))
ctx.fillStyle = i % 2 ? '#c87a3a' : '#a85a2a'
ctx.beginPath()
ctx.ellipse(lx, ly, 1.5, 0.8, this.t + i, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
private drawSnow(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
// Sehr dezent — nur 8 Flocken
ctx.globalAlpha = Math.min(1, blend * 1.5) * 0.3
ctx.fillStyle = '#fff'
for (let i = 0; i < 8; i++) {
const sx = (i * 119 + this.t * 4) % this.W
const sy = ((this.t * 10 + i * 89) % (sceneH * 0.85))
ctx.beginPath()
ctx.arc(sx + Math.sin(this.t + i) * 3, sy, 1, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
/**
* SIM-05: Treibhauseffekt-Simulator — LOGIK
*
* Vereinfachtes Klimamodell:
* - Sonneneinstrahlung (konstant ~1361 W/m²)
* - Albedo (Reflexion, ~0.3)
* - CO₂-Konzentration beeinflusst Treibhauseffekt
* - Ergebnis: Gleichgewichtstemperatur der Erde
*
* Gezielt gegen Fehlkonzept: "Ozonloch = Klimawandel"
* (Schuler 2011, Reinfried et al. 2010)
*
* Didaktischer Ablauf: Predict → Observe → Explain
*/
import { Simulation, SimulationMeta } from '@core/simulation'
const META: SimulationMeta = {
id: 'sim-05',
name: 'Treibhauseffekt-Simulator',
educationLevels: [5, 6, 7, 8], // AT: 1.4. Kl. MS, DE: 5.8., CH: Zyklus 3
primaryLevel: 5, // Primär für AT 1. Klasse MS (= 5. Schulstufe)
kompetenzbereich: 'Leben und Wirtschaften im Hinblick auf nachhaltige Ernährung',
lernziele: [
'Grundprinzip des Treibhauseffekts erklären können',
'Zusammenhang zwischen CO₂-Konzentration und Temperatur verstehen',
'Treibhauseffekt vom Ozonloch unterscheiden können',
],
basiskonzepte: ['Veränderung und Wandel', 'Maßstabsebenen und Raum'],
requiresReading: true, // Text-basierte Reflexionsfragen
dpiMinuten: 20,
typ: 'sachsimulation',
tier: 1,
}
/** Physikalische Konstanten (vereinfacht für Schulniveau) */
const SOLAR_CONSTANT = 1361 // W/m², Solarkonstante
const STEFAN_BOLTZMANN = 5.67e-8 // W/(m²·K⁴)
const PRE_INDUSTRIAL_CO2 = 280 // ppm
const CURRENT_CO2 = 425 // ppm (ca. 2026)
/**
* Berechnet die Gleichgewichtstemperatur der Erde
* basierend auf einem vereinfachten Strahlungsmodell.
*
* Ohne Treibhauseffekt: ~-18°C
* Mit natürlichem Treibhauseffekt (280 ppm): ~15°C
* Aktuell (425 ppm): ~16.1°C
*/
export function computeTemperature(co2ppm: number, albedo: number): number {
// Absorbierte Sonnenstrahlung pro m²
const absorbed = (SOLAR_CONSTANT / 4) * (1 - albedo)
// Treibhauseffekt als logarithmische Funktion der CO₂-Konzentration
// ΔT ≈ λ * ln(CO₂/CO₂_ref) — vereinfacht nach Arrhenius
const climateSensitivity = 3.0 // °C pro Verdoppelung CO₂
const deltaT = climateSensitivity * Math.log2(co2ppm / PRE_INDUSTRIAL_CO2)
// Basistemperatur ohne Treibhauseffekt
const tempNoGreenhouse = Math.pow(absorbed / STEFAN_BOLTZMANN, 0.25) - 273.15 // ~-18°C
// Natürlicher Treibhauseffekt ~33°C
const naturalGreenhouse = 33
return tempNoGreenhouse + naturalGreenhouse + deltaT
}
/**
* Berechnet Folgen der Temperaturänderung (vereinfacht)
*/
export function computeEffects(tempC: number): {
seaLevelRise: number // cm über vorindustriellem Niveau
arcticIce: number // % verbleibend (100% = vorindustriell)
extremeEvents: number // Faktor (1 = normal, 2 = doppelt so häufig)
} {
const deltaT = tempC - 15 // Differenz zum vorindustriellen Mittel
return {
seaLevelRise: Math.max(0, deltaT * 15), // ~15cm pro °C (vereinfacht)
arcticIce: Math.max(0, Math.min(100, 100 - deltaT * 12)),
extremeEvents: Math.max(1, 1 + deltaT * 0.3),
}
}
export class TreibhausSimulation extends Simulation {
constructor() {
super(META)
// Startwerte setzen
const ranges = this.getVariableRanges()
for (const [key, range] of Object.entries(ranges)) {
this.state.variables[key] = range.default
}
}
getVariableRanges() {
return {
co2: {
min: 200,
max: 1000,
default: CURRENT_CO2,
unit: 'ppm',
label: 'CO₂-Konzentration',
},
albedo: {
min: 0.1,
max: 0.6,
default: 0.3,
unit: '',
label: 'Albedo (Reflexion)',
},
}
}
compute() {
const co2 = this.getVariable('co2')
const albedo = this.getVariable('albedo')
const temp = computeTemperature(co2, albedo)
const effects = computeEffects(temp)
const results = {
temperature: Math.round(temp * 10) / 10,
seaLevelRise: Math.round(effects.seaLevelRise),
arcticIce: Math.round(effects.arcticIce),
extremeEvents: Math.round(effects.extremeEvents * 10) / 10,
tempWithoutGreenhouse: Math.round((Math.pow((SOLAR_CONSTANT / 4) * (1 - albedo) / STEFAN_BOLTZMANN, 0.25) - 273.15) * 10) / 10,
}
this.state.results = results
return results
}
protected onVariableChange(_name: string, _value: number): void {
this.compute()
}
}
+345
View File
@@ -0,0 +1,345 @@
/**
* SIM-05: Treibhauseffekt — Canvas Renderer
*
* Visualisiert:
* - Sonne → Sonnenstrahlen → Erdoberfläche
* - Wärmestrahlung von der Erde nach oben
* - CO₂-Schicht fängt Wärmestrahlung ab (je dicker, desto mehr)
* - Temperaturanzeige
* - Auswirkungen (Meeresspiegel, Eis, Extremereignisse)
*
* Stil: Skandinavisch minimal — gedeckte Farben, sanfte Animationen
*/
import { TreibhausSimulation, computeTemperature, computeEffects } from './logic'
interface Particle {
x: number; y: number; vx: number; vy: number
type: 'solar' | 'heat' | 'reflected'
life: number; maxLife: number
absorbed: boolean
}
export class TreibhausRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private sim: TreibhausSimulation
private W = 0
private H = 0
private t = 0
private particles: Particle[] = []
private animId = 0
// Layout zones (ratios of height)
private sunY = 0
private atmoTop = 0
private atmoBot = 0
private groundY = 0
private seaY = 0
// Colors — skandinavisch
private col = {
sky: '#dde3da',
space: '#c5cdc2',
sun: '#e8c84a',
sunGlow: 'rgba(232,200,74,0.15)',
solar: '#e8c84a',
heat: '#c07a6b',
reflected:'#8ab0b8',
co2: 'rgba(180,160,130,VAR)', // opacity varies
ground: '#8a9a82',
groundDark:'#6a7a62',
sea: '#9ab5b8',
ice: '#d8e0dc',
text: '#1a1a1a',
muted: '#6a6a6a',
}
constructor(container: HTMLElement, sim: TreibhausSimulation) {
this.sim = sim
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.65, 500)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Layout zones
this.sunY = this.H * 0.08
this.atmoTop = this.H * 0.25
this.atmoBot = this.H * 0.45
this.groundY = this.H * 0.7
this.seaY = this.H * 0.75
}
start(): void {
const loop = () => {
this.t += 0.016
this.update()
this.draw()
this.animId = requestAnimationFrame(loop)
}
loop()
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private update(): void {
const co2 = this.sim.getVariable('co2')
const absorptionRate = Math.min(0.9, (co2 - 200) / 800 * 0.85)
// Spawn solar particles
if (Math.random() < 0.15) {
this.particles.push({
x: this.W * 0.3 + Math.random() * this.W * 0.4,
y: 0,
vx: (Math.random() - 0.5) * 0.3,
vy: 1.5 + Math.random() * 0.5,
type: 'solar',
life: 0, maxLife: 300,
absorbed: false,
})
}
// Update particles
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i]
p.x += p.vx
p.y += p.vy
p.life++
if (p.type === 'solar' && p.y >= this.groundY) {
// Solar hits ground → becomes heat radiation going up
p.type = 'heat'
p.vy = -(1.0 + Math.random() * 0.5)
p.vx = (Math.random() - 0.5) * 0.8
p.y = this.groundY - 2
}
if (p.type === 'heat' && !p.absorbed && p.y <= this.atmoBot && p.y >= this.atmoTop) {
// Heat in CO₂ layer — chance of absorption
if (Math.random() < absorptionRate * 0.03) {
p.absorbed = true
p.vy = 0.8 + Math.random() * 0.5 // reflected back down
p.vx = (Math.random() - 0.5) * 1.2
p.type = 'reflected'
}
}
// Remove particles that leave the canvas
if (p.y < -10 || p.y > this.H + 10 || p.x < -20 || p.x > this.W + 20 || p.life > p.maxLife) {
this.particles.splice(i, 1)
}
}
// Cap particles
if (this.particles.length > 120) {
this.particles.splice(0, this.particles.length - 120)
}
}
private draw(): void {
const { ctx, W, H } = this
const co2 = this.sim.getVariable('co2')
const albedo = this.sim.getVariable('albedo')
const temp = computeTemperature(co2, albedo)
const effects = computeEffects(temp)
const co2Opacity = Math.min(0.4, (co2 - 200) / 800 * 0.35)
ctx.clearRect(0, 0, W, H)
// Background — space/sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, this.groundY)
skyGrad.addColorStop(0, this.col.space)
skyGrad.addColorStop(0.3, this.col.sky)
skyGrad.addColorStop(1, '#c8d4c6')
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, W, this.groundY)
// Sun
const sunX = W * 0.8
const sunR = 28
// Glow
const glow = ctx.createRadialGradient(sunX, this.sunY, sunR * 0.5, sunX, this.sunY, sunR * 3)
glow.addColorStop(0, 'rgba(232,200,74,0.3)')
glow.addColorStop(1, 'rgba(232,200,74,0)')
ctx.fillStyle = glow
ctx.fillRect(sunX - sunR * 3, this.sunY - sunR * 3, sunR * 6, sunR * 6)
// Sun disc
ctx.fillStyle = this.col.sun
ctx.beginPath()
ctx.arc(sunX, this.sunY, sunR, 0, Math.PI * 2)
ctx.fill()
// CO₂ layer
ctx.fillStyle = `rgba(180,160,130,${co2Opacity})`
ctx.fillRect(0, this.atmoTop, W, this.atmoBot - this.atmoTop)
// CO₂ label
ctx.fillStyle = `rgba(100,80,60,${Math.min(0.6, co2Opacity + 0.15)})`
ctx.font = '11px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`CO₂: ${co2} ppm`, 12, this.atmoTop + 16)
// Atmosphere borders (subtle)
ctx.strokeStyle = `rgba(150,130,100,${co2Opacity * 0.5})`
ctx.lineWidth = 0.5
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(0, this.atmoTop); ctx.lineTo(W, this.atmoTop)
ctx.moveTo(0, this.atmoBot); ctx.lineTo(W, this.atmoBot)
ctx.stroke()
ctx.setLineDash([])
// Ground
ctx.fillStyle = this.col.ground
ctx.fillRect(0, this.groundY, W, H - this.groundY)
// Ground detail — hills
ctx.fillStyle = this.col.groundDark
ctx.beginPath()
ctx.moveTo(0, this.groundY)
for (let x = 0; x <= W; x += 5) {
ctx.lineTo(x, this.groundY - Math.sin(x * 0.02 + 1) * 6 - Math.sin(x * 0.007) * 10)
}
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath()
ctx.fill()
// Sea (rises with temperature)
const seaRise = effects.seaLevelRise * 0.15
const seaLevel = this.seaY - seaRise
ctx.fillStyle = this.col.sea
ctx.globalAlpha = 0.7
ctx.fillRect(W * 0.55, seaLevel, W * 0.45, H - seaLevel)
ctx.globalAlpha = 1
// Ice cap (shrinks with temperature)
const iceWidth = W * 0.12 * (effects.arcticIce / 100)
if (iceWidth > 2) {
ctx.fillStyle = this.col.ice
ctx.beginPath()
ctx.ellipse(W * 0.15, this.groundY - 8, iceWidth, 8, 0, 0, Math.PI * 2)
ctx.fill()
}
// Small trees
for (let i = 0; i < 5; i++) {
const tx = W * 0.05 + i * W * 0.09
this.drawTree(ctx, tx, this.groundY - 12, 0.5 + Math.sin(i) * 0.15)
}
// Small houses
this.drawHouse(ctx, W * 0.35, this.groundY - 10, 0.7)
this.drawHouse(ctx, W * 0.42, this.groundY - 8, 0.5)
// Particles
for (const p of this.particles) {
ctx.globalAlpha = Math.max(0, 1 - p.life / p.maxLife) * 0.7
if (p.type === 'solar') {
ctx.fillStyle = this.col.solar
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
} else if (p.type === 'heat') {
ctx.fillStyle = this.col.heat
ctx.beginPath()
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2)
ctx.fill()
} else if (p.type === 'reflected') {
ctx.fillStyle = this.col.heat
ctx.globalAlpha *= 0.8
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
// Temperature display
this.drawThermometer(ctx, W - 55, this.groundY * 0.5, temp)
// Info panel bottom
ctx.fillStyle = 'rgba(255,255,255,0.75)'
ctx.fillRect(0, H - 50, W, 50)
ctx.fillStyle = this.col.text
ctx.font = 'bold 13px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`🌡️ ${temp.toFixed(1)}°C`, 15, H - 20)
ctx.font = '11px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText(`Meeresspiegel: +${effects.seaLevelRise.toFixed(0)} cm`, W * 0.25, H - 20)
ctx.fillText(`Arktis-Eis: ${effects.arcticIce.toFixed(0)}%`, W * 0.52, H - 20)
ctx.fillText(`Extremereignisse: ×${effects.extremeEvents.toFixed(1)}`, W * 0.75, H - 20)
}
private drawThermometer(ctx: CanvasRenderingContext2D, x: number, y: number, temp: number): void {
const h = 80
const w = 14
const fill = Math.max(0, Math.min(1, (temp + 20) / 50)) // -20..+30°C range
// Background
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.beginPath()
ctx.roundRect(x - w/2, y - h/2, w, h, 7)
ctx.fill()
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
ctx.lineWidth = 1
ctx.stroke()
// Fill
const fillH = h * fill * 0.85
const fillColor = temp > 17 ? '#c07a6b' : temp > 15 ? '#c4a35a' : '#4a7c8a'
ctx.fillStyle = fillColor
ctx.beginPath()
ctx.roundRect(x - w/2 + 2, y + h/2 - fillH - 2, w - 4, fillH, 4)
ctx.fill()
// Temperature text
ctx.fillStyle = this.col.text
ctx.font = 'bold 11px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${temp.toFixed(1)}°`, x, y - h/2 - 6)
}
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
ctx.fillStyle = '#5a5a4a'
ctx.fillRect(x - 1.5 * s, y, 3 * s, 10 * s)
ctx.fillStyle = '#6a8a5e'
ctx.beginPath()
ctx.arc(x, y - 2 * s, 8 * s, 0, Math.PI * 2)
ctx.fill()
}
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
// Wall
ctx.fillStyle = '#d8c8b0'
ctx.fillRect(x - 8 * s, y - 10 * s, 16 * s, 12 * s)
// Roof
ctx.fillStyle = '#c07a6b'
ctx.beginPath()
ctx.moveTo(x - 10 * s, y - 10 * s)
ctx.lineTo(x, y - 18 * s)
ctx.lineTo(x + 10 * s, y - 10 * s)
ctx.closePath()
ctx.fill()
// Window
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 3 * s, y - 7 * s, 6 * s, 5 * s)
}
}
@@ -0,0 +1,504 @@
/**
* Erdbeben-Spiel — Canvas Renderer
*
* Ansicht: Stadt von der Seite, im Vordergrund Häuser verschiedener Bauart,
* im Hintergrund Berge und ein Verwerfungs-Hinweis (rot pulsierende Linie zeigt Stress).
*
* Bei Beben: kurzer Shake, einstürzende Häuser werden zu Trümmern.
* Zeitleiste am Rand mit Markierungen für vergangene Beben.
*/
import { ErdbebenGame, BUILDING_TYPES } from './game'
interface PlacedBuilding {
typeId: string
x: number // 0..1
scale: number
ownerId: string
intact: boolean
builtTick: number
}
export class ErdbebenRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private game: ErdbebenGame
private W = 0
private H = 0
private t = 0
private animId = 0
private placed: PlacedBuilding[] = []
private knownIds = new Set<string>()
private shake = 0
private shakeUntil = 0
private lastDeaths = 0
private lastQuakeShown = 0
constructor(container: HTMLElement, game: ErdbebenGame) {
this.game = game
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dde3da;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.55, 420)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
private seededRand(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453
return x - Math.floor(x)
}
start(): void {
let lastFrame = performance.now()
const loop = (now: number) => {
const dt = (now - lastFrame) / 1000
lastFrame = now
this.t += dt
this.updateScene()
this.draw()
this.animId = requestAnimationFrame(loop)
}
this.animId = requestAnimationFrame(loop)
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private updateScene(): void {
// Add new buildings to scene
const owned = this.game.getOwnedBuildings()
let counterByType: Record<string, number> = {}
for (const b of owned) {
counterByType[b.typeId] = 0
for (let i = 0; i < b.count; i++) {
const id = `${b.typeId}-${i}`
const intact = i >= b.damaged
if (!this.knownIds.has(id)) {
this.knownIds.add(id)
this.placed.push(this.placeBuilding(b.typeId, i, this.game.getSnapshot().tick))
}
// Update intact state
const obj = this.placed.find(p => p.ownerId === id)
if (obj) obj.intact = intact
}
}
// Trigger shake on new earthquake
const snap = this.game.getSnapshot()
const lastMag = snap.resources.lastQuake
const deaths = snap.resources.deaths
if (deaths > this.lastDeaths) {
this.shake = lastMag * 1.5
this.shakeUntil = this.t + 1.2
this.lastDeaths = deaths
}
if (this.t > this.shakeUntil) {
this.shake *= 0.85
if (this.shake < 0.05) this.shake = 0
}
}
private placeBuilding(typeId: string, index: number, tick: number): PlacedBuilding {
const id = `${typeId}-${index}`
// Position depends on type (different "districts")
let x = 0.1
let scale = 1
if (typeId === 'slum') {
// Slums on the far edges
const slot = index % 8
x = 0.04 + slot * 0.025 + this.seededRand(index * 13 + 7) * 0.015
scale = 0.7 + this.seededRand(index * 19) * 0.2
} else if (typeId === 'simple') {
// Simple houses in the middle-left area
const slot = index % 8
x = 0.25 + slot * 0.04 + this.seededRand(index * 17 + 3) * 0.02
scale = 0.85 + this.seededRand(index * 23) * 0.2
} else if (typeId === 'reinforced') {
// Reinforced in middle-right
const slot = index % 6
x = 0.55 + slot * 0.05 + this.seededRand(index * 29 + 5) * 0.02
scale = 0.95 + this.seededRand(index * 31) * 0.15
} else if (typeId === 'quake-proof') {
// Quake-proof on the right
const slot = index % 5
x = 0.83 + slot * 0.025 + this.seededRand(index * 37 + 11) * 0.01
scale = 1.05
} else if (typeId === 'school') {
// School at the center, slightly bigger
x = 0.48
scale = 1.4
}
return { typeId, x, scale, ownerId: id, intact: true, builtTick: tick }
}
// ============================================================
// RENDERING
// ============================================================
private draw(): void {
const { ctx, W, H } = this
const snap = this.game.getSnapshot()
const tick = snap.tick
ctx.save()
if (this.shake > 0.01) {
const sx = (Math.random() - 0.5) * this.shake
const sy = (Math.random() - 0.5) * this.shake
ctx.translate(sx, sy)
}
ctx.clearRect(-20, -20, W + 40, H + 40)
const timelineH = 28
const sceneH = H - timelineH
const groundY = sceneH * 0.78
// Sky
const sky = ctx.createLinearGradient(0, 0, 0, groundY)
sky.addColorStop(0, '#c8d4d8')
sky.addColorStop(0.6, '#d8e0d8')
sky.addColorStop(1, '#e0e4d8')
ctx.fillStyle = sky
ctx.fillRect(0, 0, W, groundY)
// Sun
ctx.fillStyle = '#e8c84a'
ctx.beginPath()
ctx.arc(W * 0.85, sceneH * 0.13, 16, 0, Math.PI * 2)
ctx.fill()
// Background mountains
ctx.fillStyle = '#9aa494'
ctx.beginPath()
ctx.moveTo(0, groundY)
for (let x = 0; x <= W; x += 30) {
const my = groundY - 35 - Math.sin(x * 0.005 + 1) * 18 - Math.sin(x * 0.013 + 0.3) * 10
ctx.lineTo(x, my)
}
ctx.lineTo(W, groundY)
ctx.closePath()
ctx.fill()
// Closer mountain layer (visualizing the fault zone)
ctx.fillStyle = '#7a8474'
ctx.beginPath()
ctx.moveTo(0, groundY)
for (let x = 0; x <= W; x += 25) {
const my = groundY - 18 - Math.sin(x * 0.008 + 0.5) * 10
ctx.lineTo(x, my)
}
ctx.lineTo(W, groundY)
ctx.closePath()
ctx.fill()
// === Fault zone visualization ===
// A zigzag red-pulsing line in the mountain that shows tectonic stress
const nextIn = this.game.getNextQuakeIn ? this.game.getNextQuakeIn() : 5
const stressLevel = Math.max(0, Math.min(1, (12 - nextIn) / 12))
const pulseAlpha = (Math.sin(this.t * 2) * 0.3 + 0.5) * stressLevel
ctx.strokeStyle = `rgba(180,80,60,${pulseAlpha * 0.5})`
ctx.lineWidth = 1.5 + stressLevel * 1.5
ctx.setLineDash([3, 4])
ctx.beginPath()
let lx = 0
let ly = groundY - 25
ctx.moveTo(lx, ly)
for (let i = 0; i < 20; i++) {
lx += W / 20
ly = groundY - 22 + (Math.sin(i * 1.7) * 5) + (Math.cos(i * 0.9) * 3)
ctx.lineTo(lx, ly)
}
ctx.stroke()
ctx.setLineDash([])
// Ground
ctx.fillStyle = '#9a9a82'
ctx.fillRect(0, groundY, W, sceneH - groundY)
ctx.fillStyle = '#7a7a62'
ctx.fillRect(0, groundY + 3, W, 5)
// ===== BUILDINGS =====
// Sort by x for correct depth
const sorted = [...this.placed].sort((a, b) => a.x - b.x)
for (const obj of sorted) {
const x = obj.x * W
this.drawBuilding(ctx, x, groundY, obj)
}
// ===== PEOPLE WAITING (left edge, in front) =====
const waiting = snap.resources.waiting || 0
const waitingDots = Math.min(20, Math.floor(waiting / 30))
if (waitingDots > 0) {
// Draw a small "tent camp" with stick figures
for (let i = 0; i < waitingDots; i++) {
const px = 8 + (i % 4) * 7 + Math.floor(i / 4) * 0.5
const py = groundY - 1
// Tent
if (i % 4 === 0) {
ctx.fillStyle = '#c4a880'
ctx.beginPath()
ctx.moveTo(px - 5, py)
ctx.lineTo(px, py - 7)
ctx.lineTo(px + 5, py)
ctx.closePath()
ctx.fill()
}
// Person
ctx.fillStyle = '#3a3a3a'
ctx.fillRect(px - 0.5, py - 4, 1, 4)
ctx.beginPath()
ctx.arc(px, py - 5, 1, 0, Math.PI * 2)
ctx.fill()
}
// Label
ctx.fillStyle = 'rgba(180,100,80,0.9)'
ctx.font = 'bold 9px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`${Math.round(waiting)} ohne Wohnung`, 8, groundY - 30)
}
// ===== HUD =====
const year = 1975 + tick
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.beginPath()
ctx.roundRect(12, 12, 230, 28, 8)
ctx.fill()
ctx.fillStyle = '#1a1a1a'
ctx.font = 'bold 12px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`Jahr ${year}`, 22, 30)
ctx.fillStyle = '#5a5a5a'
ctx.font = '10px Inter, sans-serif'
ctx.fillText(`Bevölkerung: ${Math.round(snap.resources.population)} · Tote: ${Math.round(snap.resources.deaths)}`, 75, 30)
// Stress indicator
if (stressLevel > 0.5) {
ctx.fillStyle = `rgba(180,80,60,${pulseAlpha})`
ctx.font = 'bold 10px Inter, sans-serif'
ctx.textAlign = 'right'
ctx.fillText('⚠ Tektonische Spannung baut sich auf', W - 14, 28)
}
ctx.restore() // shake
// ===== ZEITLEISTE =====
this.drawTimeline(ctx, timelineH, tick, snap.events)
}
private drawTimeline(ctx: CanvasRenderingContext2D, h: number, currentTick: number, events: any[]): void {
const { W, H } = this
const y = H - h
const marginX = 40
ctx.fillStyle = 'rgba(255,255,255,0.92)'
ctx.fillRect(0, y, W, h)
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, y); ctx.lineTo(W, y)
ctx.stroke()
const lineY = y + h / 2 + 2
const lineX0 = marginX
const lineX1 = W - marginX
ctx.strokeStyle = '#c8c4b8'
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(lineX1, lineY)
ctx.stroke()
const totalTicks = 50
for (let i = 0; i <= 5; i++) {
const decade = i * 10
const x = lineX0 + (decade / totalTicks) * (lineX1 - lineX0)
ctx.strokeStyle = '#a8a497'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, lineY - 3); ctx.lineTo(x, lineY + 3)
ctx.stroke()
ctx.fillStyle = '#7a7468'
ctx.font = '9px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${1975 + decade}`, x, lineY + 14)
}
const progress = Math.min(1, currentTick / totalTicks)
const markerX = lineX0 + progress * (lineX1 - lineX0)
ctx.strokeStyle = '#4a7c8a'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(lineX0, lineY); ctx.lineTo(markerX, lineY)
ctx.stroke()
ctx.fillStyle = '#4a7c8a'
ctx.beginPath()
ctx.arc(markerX, lineY, 5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.arc(markerX, lineY, 2, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#4a7c8a'
ctx.font = 'bold 10px Inter, system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${1975 + currentTick}`, markerX, lineY - 8)
// Event markers — focus on quakes
for (const ev of events) {
if (ev.tick > currentTick) continue
const ex = lineX0 + (ev.tick / totalTicks) * (lineX1 - lineX0)
let color = '#8a8a8a'
if (ev.severity === 'danger') color = '#c0503c'
else if (ev.severity === 'warning') color = '#c4a35a'
else if (ev.severity === 'success') color = '#5a8a5e'
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(ex, lineY - 8, 2.5, 0, Math.PI * 2)
ctx.fill()
}
}
// ============================================================
// BUILDING DRAWING
// ============================================================
private drawBuilding(ctx: CanvasRenderingContext2D, x: number, baseY: number, obj: PlacedBuilding): void {
const t = BUILDING_TYPES.find(b => b.id === obj.typeId)
if (!t) return
if (!obj.intact) {
// Rubble
ctx.fillStyle = '#7a6a5a'
ctx.fillRect(x - 8 * obj.scale, baseY - 4 * obj.scale, 16 * obj.scale, 4 * obj.scale)
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 6 * obj.scale, baseY - 6 * obj.scale, 4 * obj.scale, 2 * obj.scale)
ctx.fillRect(x + 1 * obj.scale, baseY - 6 * obj.scale, 5 * obj.scale, 2 * obj.scale)
return
}
const s = obj.scale
if (obj.typeId === 'slum') {
// Wonky shack
ctx.fillStyle = '#a89878'
ctx.fillRect(x - 6 * s, baseY - 8 * s, 12 * s, 8 * s)
ctx.fillStyle = '#8a7858'
ctx.beginPath()
ctx.moveTo(x - 7 * s, baseY - 8 * s)
ctx.lineTo(x - 1, baseY - 12 * s)
ctx.lineTo(x + 7 * s, baseY - 7 * s)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 1 * s, baseY - 4 * s, 2 * s, 4 * s)
} else if (obj.typeId === 'simple') {
// Simple house
ctx.fillStyle = '#e8d8b8'
ctx.fillRect(x - 8 * s, baseY - 12 * s, 16 * s, 12 * s)
ctx.fillStyle = '#b06a5a'
ctx.beginPath()
ctx.moveTo(x - 10 * s, baseY - 12 * s)
ctx.lineTo(x, baseY - 19 * s)
ctx.lineTo(x + 10 * s, baseY - 12 * s)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 3 * s, baseY - 9 * s, 6 * s, 4 * s)
ctx.fillStyle = '#6a4a3a'
ctx.fillRect(x - 1.5 * s, baseY - 4 * s, 3 * s, 4 * s)
} else if (obj.typeId === 'reinforced') {
// Reinforced — slightly larger, concrete look
ctx.fillStyle = '#c8c4b4'
ctx.fillRect(x - 9 * s, baseY - 16 * s, 18 * s, 16 * s)
// Visible reinforcement bands
ctx.fillStyle = '#8a8478'
ctx.fillRect(x - 9 * s, baseY - 13 * s, 18 * s, 1)
ctx.fillRect(x - 9 * s, baseY - 7 * s, 18 * s, 1)
// Roof
ctx.fillStyle = '#7a6a5a'
ctx.fillRect(x - 10 * s, baseY - 17 * s, 20 * s, 2 * s)
// Windows
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 6 * s, baseY - 11 * s, 4 * s, 3 * s)
ctx.fillRect(x + 2 * s, baseY - 11 * s, 4 * s, 3 * s)
ctx.fillStyle = '#6a4a3a'
ctx.fillRect(x - 1.5 * s, baseY - 5 * s, 3 * s, 5 * s)
} else if (obj.typeId === 'quake-proof') {
// Modern building — taller, gray, with steel framework
ctx.fillStyle = '#b8c0c4'
ctx.fillRect(x - 9 * s, baseY - 22 * s, 18 * s, 22 * s)
// Steel frame visible
ctx.strokeStyle = '#5a6a74'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(x - 9 * s, baseY - 22 * s); ctx.lineTo(x - 9 * s, baseY)
ctx.moveTo(x + 9 * s, baseY - 22 * s); ctx.lineTo(x + 9 * s, baseY)
ctx.moveTo(x - 9 * s, baseY - 18 * s); ctx.lineTo(x + 9 * s, baseY - 18 * s)
ctx.moveTo(x - 9 * s, baseY - 11 * s); ctx.lineTo(x + 9 * s, baseY - 11 * s)
ctx.stroke()
// Many windows
ctx.fillStyle = '#a8c8d0'
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 3; col++) {
ctx.fillRect(x - 7 * s + col * 5 * s, baseY - 20 * s + row * 5 * s, 3 * s, 3 * s)
}
}
// Flat roof
ctx.fillStyle = '#6a747c'
ctx.fillRect(x - 10 * s, baseY - 23 * s, 20 * s, 2 * s)
} else if (obj.typeId === 'school') {
// School — wider, low building, with flag
ctx.fillStyle = '#e8e0c8'
ctx.fillRect(x - 14 * s, baseY - 14 * s, 28 * s, 14 * s)
// Roof
ctx.fillStyle = '#5a8a5e'
ctx.beginPath()
ctx.moveTo(x - 16 * s, baseY - 14 * s)
ctx.lineTo(x, baseY - 22 * s)
ctx.lineTo(x + 16 * s, baseY - 14 * s)
ctx.closePath()
ctx.fill()
// Door
ctx.fillStyle = '#5a4a3a'
ctx.fillRect(x - 2 * s, baseY - 7 * s, 4 * s, 7 * s)
// Windows
ctx.fillStyle = '#a8c8d0'
ctx.fillRect(x - 11 * s, baseY - 11 * s, 5 * s, 4 * s)
ctx.fillRect(x + 6 * s, baseY - 11 * s, 5 * s, 4 * s)
// Flagpole
ctx.strokeStyle = '#5a5a5a'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, baseY - 22 * s); ctx.lineTo(x, baseY - 30 * s)
ctx.stroke()
ctx.fillStyle = '#c0503c'
ctx.beginPath()
ctx.moveTo(x, baseY - 30 * s); ctx.lineTo(x + 6 * s, baseY - 28 * s); ctx.lineTo(x, baseY - 26 * s)
ctx.closePath()
ctx.fill()
}
}
}
+456
View File
@@ -0,0 +1,456 @@
/**
* SIM-07: Stadtplaner*in in Erdbebenregion — Spielbare Erdbeben-Simulation
*
* Du übernimmst 1975 als Bürgermeister*in eine Kleinstadt in einer Erdbebenregion.
* Über 50 Jahre (bis 2025) wachsen die Einwohnerzahlen — du musst Wohnraum bauen.
*
* Du hast die Wahl:
* - Slum-Häuser (5 €): schnell und billig, aber bei Beben tödlich
* - Standard-Häuser (15 €): solide gebaut, halten kleinere Beben aus
* - Erdbebensichere Häuser (40 €): teuer, aber sicher
*
* Erdbeben kommen mehrmals während des Spiels — manchmal stark, manchmal schwach.
* Du gewinnst, wenn 2025 alle Familien wohnen UND weniger als 200 Menschen
* durch Erdbeben gestorben sind UND du nicht pleite bist.
*
* Fachlich korrekt:
* - Magnitude-Verteilung folgt Gutenberg-Richter (häufig schwach, selten stark)
* - Schäden hängen exponentiell von Magnitude UND Bauqualität ab
* - Die Botschaft: "Naturgefahr ≠ Naturkatastrophe" (Vulnerabilität entscheidet)
*/
import { GameEngine, type GameMeta } from '@core/game-engine'
const META: GameMeta = {
id: 'sim-07',
title: 'Stadtplaner*in in Erdbebenregion',
description: '50 Jahre lang baust du eine Stadt in einer Erdbebenregion auf. Wieviele Menschenleben kannst du retten?',
msPerTick: 4000,
tickUnit: 'Jahr',
maxTicks: 50,
tutorialSteps: 4,
}
const START_YEAR = 1975
export interface BuildingType {
id: string
name: string
emoji: string
description: string
cost: number
capacity: number // Wieviele Menschen wohnen drin
quality: number // 0..1 (Bruchresistenz)
upkeep: number
}
export const BUILDING_TYPES: BuildingType[] = [
{
id: 'slum',
name: 'Slum-Häuser',
emoji: '🏚️',
description: 'Sehr billig. Bewohner haben keine Wahl. Stürzt bei Beben ab Magnitude 5 ein.',
cost: 30,
capacity: 200,
quality: 0.05,
upkeep: 1,
},
{
id: 'simple',
name: 'Einfache Häuser',
emoji: '🏘️',
description: 'Solide Bauweise. Übersteht schwache Beben.',
cost: 80,
capacity: 150,
quality: 0.35,
upkeep: 3,
},
{
id: 'reinforced',
name: 'Verstärkte Häuser',
emoji: '🏠',
description: 'Mit Stahl verstärkt. Übersteht mittlere Beben gut.',
cost: 180,
capacity: 120,
quality: 0.65,
upkeep: 6,
},
{
id: 'quake-proof',
name: 'Erdbebensichere Häuser',
emoji: '🏛️',
description: 'Modernster Standard. Übersteht selbst starke Beben fast unbeschadet.',
cost: 400,
capacity: 100,
quality: 0.92,
upkeep: 12,
},
{
id: 'school',
name: 'Schule (sicher)',
emoji: '🏫',
description: 'Erdbebensicher. Senkt Opferzahlen bei Beben durch Aufklärung & Übungen.',
cost: 250,
capacity: 0,
quality: 0.95,
upkeep: 8,
},
]
interface OwnedBuilding {
typeId: string
count: number
damaged: number // wieviele beschädigt nach letztem Beben
}
interface PastQuake {
year: number
magnitude: number
deaths: number
homeless: number
}
export class ErdbebenGame extends GameEngine {
private buildings: OwnedBuilding[] = []
private pastQuakes: PastQuake[] = []
// Nicht untergebrachte Menschen (Wartende auf Wohnraum)
private waiting = 0
private hasSchool = false
private nextQuakeIn = 0 // Ticks bis zum nächsten Beben (zufällig 6-12)
private firedEvents = new Set<string>()
constructor() {
super(META)
this.setupResources()
this.setupGoals()
this.setupTutorial()
this.setupVariables()
// Erstes Beben kommt nach 8-14 Jahren
this.nextQuakeIn = 8 + Math.floor(Math.random() * 6)
}
private setupResources(): void {
this.addResource({
id: 'budget',
name: 'Budget',
icon: '💰',
initial: 250,
unit: '€',
format: (v) => `${Math.round(v)}`,
})
this.addResource({
id: 'population',
name: 'Bevölkerung',
icon: '👥',
initial: 500,
unit: '',
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
})
this.addResource({
id: 'housed',
name: 'Wohnraum',
icon: '🏠',
initial: 0,
unit: 'Plätze',
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
})
this.addResource({
id: 'waiting',
name: 'Ohne Wohnung',
icon: '⛺',
initial: 0,
unit: 'Menschen',
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
})
this.addResource({
id: 'deaths',
name: 'Opfer (gesamt)',
icon: '🕯️',
initial: 0,
unit: '',
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
})
this.addResource({
id: 'lastQuake',
name: 'Letzte Magnitude',
icon: '📊',
initial: 0,
unit: 'M',
format: (v) => v > 0 ? `M ${v.toFixed(1)}` : '—',
})
}
private setupGoals(): void {
this.addGoal({
id: 'survive',
title: 'Bis 2025 regieren',
description: '50 Jahre Stadtplanung — von 1975 bis 2025.',
check: (g) => (g as ErdbebenGame).tick >= 50,
progress: (g) => Math.min(100, ((g as ErdbebenGame).tick / 50) * 100),
required: true,
})
this.addGoal({
id: 'housing',
title: 'Alle Bewohner unterbringen',
description: 'Maximal 50 Menschen ohne Wohnung am Ende.',
check: (g) => (g as ErdbebenGame).waiting <= 50,
progress: (g) => {
const w = (g as ErdbebenGame).waiting
return Math.max(0, Math.min(100, 100 - w / 5))
},
required: true,
})
this.addGoal({
id: 'safe',
title: 'Weniger als 200 Tote',
description: 'Halte die Opferzahl durch Erdbeben unter 200.',
check: (g) => g.getResource('deaths') < 200,
progress: (g) => Math.max(0, Math.min(100, 100 - g.getResource('deaths') / 2)),
required: true,
})
this.addGoal({
id: 'budget',
title: 'Nicht pleite gehen',
description: 'Halte ein positives Budget.',
check: (g) => g.getResource('budget') > 0,
required: true,
})
}
private setupTutorial(): void {
this.setTutorial([
{
triggerTick: 0,
title: 'Willkommen, Bürgermeister*in!',
text: 'Es ist 1975. Du übernimmst eine Kleinstadt mit 500 Einwohnern in einer Erdbebenregion.\n\nIn den nächsten 50 Jahren werden viele neue Familien zuziehen. Du musst entscheiden: Welche Häuser baust du?\n\nVorsicht: Erdbeben kommen unangekündigt. Manche schwach, manche stark.',
unlocks: ['budget'],
},
{
triggerTick: 0,
title: 'Wachstum & Wohnraum',
text: 'Jedes Jahr wächst die Bevölkerung um etwa 80 Personen. Du musst rechtzeitig Wohnraum schaffen.\n\nMenschen ohne Wohnung warten in Notunterkünften — und sie werden unzufrieden. Bei Beben sterben sie überproportional oft.\n\nBaue klug voraus, nicht erst wenn es zu spät ist.',
unlocks: ['population'],
},
{
triggerTick: 0,
title: 'Bauqualität entscheidet',
text: 'Die wichtigste Frage: Wie stabil baust du?\n\n🏚️ Slum-Häuser: 30 € — wirken verlockend billig, aber bei Beben sterben viele Menschen darin.\n🏘️ Einfach: 80 € — übersteht schwache Beben.\n🏠 Verstärkt: 180 € — gute Wahl für mittlere Beben.\n🏛️ Erdbebensicher: 400 € — sicher, aber teuer.\n\n🏫 Eine Schule senkt zusätzlich die Opferzahlen durch Aufklärung.',
unlocks: ['shop'],
},
{
triggerTick: 0,
title: 'Die zentrale Erkenntnis',
text: 'Naturgefahren werden zu Naturkatastrophen — durch unsere Entscheidungen.\n\nGleiche Magnitude, andere Bauqualität: 5 Tote oder 500.\n\nDu hast 250 € Startbudget. Pro Jahr bekommst du Steuern (~20 € pro 1000 Einwohner). Wartung deiner Gebäude bezahlt sich davon.\n\nDeine Aufgabe: alle bis 2025 sicher unterbringen und unter 200 Opfer bleiben.\n\nLos geht\'s! 🏗️',
unlocks: ['controls'],
},
])
}
private setupVariables(): void {
this.setVariable('totalCapacity', 0)
this.setVariable('avgQuality', 0)
this.setVariable('upkeepTotal', 0)
}
private recalc(): void {
let cap = 0
let qSum = 0
let qCount = 0
let upkeep = 0
for (const b of this.buildings) {
const t = BUILDING_TYPES.find(x => x.id === b.typeId)
if (!t) continue
const aliveCount = b.count - b.damaged
cap += t.capacity * aliveCount
qSum += t.quality * aliveCount
qCount += aliveCount
upkeep += t.upkeep * aliveCount
}
this.setVariable('totalCapacity', cap)
this.setVariable('avgQuality', qCount > 0 ? qSum / qCount : 0)
this.setVariable('upkeepTotal', upkeep)
}
buyBuilding(typeId: string): boolean {
const t = BUILDING_TYPES.find(x => x.id === typeId)
if (!t) return false
const budget = this.getResource('budget')
if (budget < t.cost) {
this.addEvent('error', `Nicht genug Budget für ${t.name}`, 'warning')
return false
}
this.changeResource('budget', -t.cost)
const existing = this.buildings.find(x => x.typeId === typeId)
if (existing) {
existing.count++
} else {
this.buildings.push({ typeId, count: 1, damaged: 0 })
}
if (typeId === 'school') this.hasSchool = true
this.recalc()
this.addEvent('build', `${t.emoji} ${t.name} gebaut (-${t.cost} €)`, 'success')
this.notify()
return true
}
getOwnedBuildings(): OwnedBuilding[] {
return this.buildings
}
getBuildingCount(id: string): number {
return this.buildings.find(b => b.typeId === id)?.count ?? 0
}
getStartYear(): number { return START_YEAR }
protected simulateTick(): void {
// 1. Bevölkerung wächst
const growth = 80 + Math.floor(Math.random() * 30) - 15
this.changeResource('population', growth)
// 2. Wohnraum berechnen
const totalPop = this.getResource('population')
const totalCap = this.getVariable('totalCapacity')
const housed = Math.min(totalPop, totalCap)
this.waiting = Math.max(0, totalPop - totalCap)
this.setResource('housed', housed)
this.setResource('waiting', this.waiting)
// 3. Steuern (proportional zur untergebrachten Bevölkerung)
const income = Math.round((housed / 1000) * 25 + 5)
this.changeResource('budget', income)
// 4. Wartungskosten
const upkeep = this.getVariable('upkeepTotal')
this.changeResource('budget', -upkeep)
// 5. Erdbeben?
this.nextQuakeIn--
if (this.nextQuakeIn <= 0) {
this.triggerEarthquake()
// Nächstes Beben in 5-12 Jahren
this.nextQuakeIn = 5 + Math.floor(Math.random() * 8)
}
// 6. Schäden über die Zeit reparieren (langsam)
for (const b of this.buildings) {
if (b.damaged > 0 && Math.random() < 0.3) {
b.damaged--
}
}
this.recalc()
// 7. Events
if (this.tick === 5 && this.waiting > 100 && !this.firedEvent('housing-1')) {
this.addEvent('housing-1', '⚠️ Über 100 Menschen leben in Notunterkünften!', 'warning')
}
if (this.tick === 20 && this.getResource('deaths') === 0 && !this.firedEvent('praise-1')) {
this.addEvent('praise-1', '👏 20 Jahre ohne Opfer — die Bürger danken dir!', 'success')
}
if (this.hasSchool && !this.firedEvent('school-built')) {
this.addEvent('school-built', '🏫 Die neue Schule informiert die Bürger über Erdbebenschutz.', 'success')
this.firedEvents.add('school-built')
}
}
private triggerEarthquake(): void {
// Magnitude folgt grob Gutenberg-Richter — kleinere Beben häufiger
// 60% schwach (4-5), 30% mittel (5-6.5), 10% stark (6.5-8)
const r = Math.random()
let magnitude: number
if (r < 0.6) magnitude = 4 + Math.random()
else if (r < 0.9) magnitude = 5 + Math.random() * 1.5
else magnitude = 6.5 + Math.random() * 1.5
this.setResource('lastQuake', magnitude)
// Schäden berechnen
let totalDeaths = 0
let totalHomeless = 0
let totalDamaged = 0
for (const b of this.buildings) {
const t = BUILDING_TYPES.find(x => x.id === b.typeId)
if (!t || t.capacity === 0) continue
// Wahrscheinlichkeit, dass ein Gebäude einstürzt:
// f(magnitude, quality)
// Bei Magnitude 4 + quality 0.05 → ~30% collapse
// Bei Magnitude 7 + quality 0.05 → ~95% collapse
// Bei Magnitude 7 + quality 0.92 → ~10% collapse
const stress = Math.max(0, (magnitude - 3) / 5) // 0..1
const collapseProbability = Math.max(0, Math.min(0.95, stress - t.quality * 0.9))
const aliveCount = b.count - b.damaged
let collapsedThisQuake = 0
for (let i = 0; i < aliveCount; i++) {
if (Math.random() < collapseProbability) {
collapsedThisQuake++
}
}
b.damaged += collapsedThisQuake
totalDamaged += collapsedThisQuake
// Tote pro eingestürztem Gebäude
// Schule senkt um 50%
const schoolFactor = this.hasSchool ? 0.5 : 1
const deathsPerCollapse = Math.round(t.capacity * 0.15 * schoolFactor * (1 - t.quality * 0.5))
totalDeaths += collapsedThisQuake * deathsPerCollapse
totalHomeless += collapsedThisQuake * t.capacity
}
// Nicht-untergebrachte Menschen sterben überproportional
if (this.waiting > 0) {
const stress = Math.max(0, (magnitude - 3) / 5)
const waitingDeaths = Math.round(this.waiting * stress * 0.15)
totalDeaths += waitingDeaths
}
if (totalDeaths > 0) {
this.changeResource('population', -totalDeaths)
this.changeResource('deaths', totalDeaths)
}
this.pastQuakes.push({
year: this.tick,
magnitude,
deaths: totalDeaths,
homeless: totalHomeless,
})
// Event-Meldung
if (magnitude < 5) {
this.addEvent('quake', `📊 Schwaches Beben (M ${magnitude.toFixed(1)}). ${totalDeaths > 0 ? `${totalDeaths} Opfer.` : 'Keine Opfer.'}`, totalDeaths > 0 ? 'warning' : 'info')
} else if (magnitude < 6.5) {
this.addEvent('quake', `⚠️ Mittleres Beben (M ${magnitude.toFixed(1)}). ${totalDeaths} Opfer, ${totalDamaged} Häuser beschädigt.`, 'warning')
} else {
this.addEvent('quake', `🆘 STARKES BEBEN (M ${magnitude.toFixed(1)})! ${totalDeaths} Opfer, ${totalDamaged} Häuser zerstört.`, 'danger')
}
this.notify()
}
private firedEvent(id: string): boolean {
if (this.firedEvents.has(id)) return true
this.firedEvents.add(id)
return false
}
getPastQuakes(): PastQuake[] {
return this.pastQuakes
}
getNextQuakeIn(): number {
return this.nextQuakeIn
}
protected checkLossCondition(): boolean {
if (this.getResource('budget') < -300) return true
if (this.getResource('deaths') > 1000) return true
return false
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* SIM-07: Erdbeben-Simulator — LOGIK
*
* Modell:
* - Zwei tektonische Platten bewegen sich gegeneinander
* - Spannung baut sich auf (abhängig von Geschwindigkeit und Gesteinstyp)
* - Bei Überschreitung der Bruchspannung → Erdbeben
* - Magnitude abhängig von akkumulierter Spannung
* - Auswirkungen auf zwei Städte (arm vs. reich) berechnet
*
* Didaktik:
* - Fehlkonzepte: "Erdbeben sind zufällig", "Stärke = Schaden"
* - Kernaussage: Gleiche Magnitude, unterschiedliche Auswirkungen
*/
import { Simulation, SimulationMeta } from '@core/simulation'
const META: SimulationMeta = {
id: 'sim-07',
name: 'Erdbeben-Simulator',
educationLevels: [5, 6, 7, 8],
primaryLevel: 5,
kompetenzbereich: 'Leben und Wirtschaften unter Beachtung der natürlichen Prozesse',
lernziele: [
'Zusammenhang zwischen Plattenbewegung und Erdbeben verstehen',
'Unterschied zwischen Magnitude und Schadensausmaß erkennen',
'Ungleiche Betroffenheit durch Naturgefahren analysieren',
],
basiskonzepte: ['Veränderung und Wandel', 'Gemeinsamkeiten und Unterschiede'],
dpiMinuten: 25,
typ: 'sachsimulation',
tier: 1,
requiresReading: true,
}
export interface QuakeEvent {
time: number
magnitude: number
epicenterX: number
depth: number
}
export interface CityImpact {
name: string
type: 'rich' | 'poor'
distance: number
damage: number // 0-100%
casualties: number // estimated
buildingCollapse: number // %
recovery: string // "Monate" | "Jahre" | "Jahrzehnte"
}
/**
* Berechnet die Magnitude basierend auf akkumulierter Spannung
* Vereinfachtes Gutenberg-Richter-artiges Modell
*/
export function computeMagnitude(stress: number, rockHardness: number): number {
// log-Beziehung: mehr Spannung → exponentiell stärkeres Beben
const base = Math.log10(Math.max(1, stress * rockHardness)) + 2
return Math.min(9.5, Math.max(1, base))
}
/**
* Berechnet die Auswirkungen auf eine Stadt
*/
export function computeCityImpact(
magnitude: number,
distance: number,
buildingQuality: number // 0-1 (0=schlecht, 1=erdbebensicher)
): CityImpact {
// Intensität nimmt mit Entfernung ab (vereinfacht)
const distanceFactor = Math.max(0.1, 1 - distance / 500)
const intensity = magnitude * distanceFactor
// Schaden abhängig von Bauqualität
const rawDamage = Math.pow(intensity / 9, 2.5) * 100
const damage = Math.min(100, rawDamage * (1 - buildingQuality * 0.8))
// Opfer proportional zu Schaden und inverser Bauqualität
const casualties = Math.round(damage * (1 - buildingQuality) * 5)
// Gebäudekollaps
const buildingCollapse = Math.min(100, rawDamage * (1 - buildingQuality * 0.9))
// Erholungszeit
let recovery = 'Wochen'
if (damage > 70) recovery = 'Jahrzehnte'
else if (damage > 40) recovery = 'Jahre'
else if (damage > 15) recovery = 'Monate'
return {
name: '',
type: buildingQuality > 0.6 ? 'rich' : 'poor',
distance,
damage: Math.round(damage),
casualties,
buildingCollapse: Math.round(buildingCollapse),
recovery,
}
}
export class ErdbebenSimulation extends Simulation {
private stress = 0
private quakeHistory: QuakeEvent[] = []
private tickCount = 0
constructor() {
super(META)
const ranges = this.getVariableRanges()
for (const [key, range] of Object.entries(ranges)) {
this.state.variables[key] = range.default
}
}
getVariableRanges() {
return {
plateSpeed: {
min: 1, max: 15, default: 5,
unit: 'cm/Jahr', label: 'Plattengeschwindigkeit',
},
rockHardness: {
min: 0.3, max: 1.5, default: 0.8,
unit: '', label: 'Gesteinshärte',
},
buildingQualityA: {
min: 0, max: 1, default: 0.8,
unit: '', label: 'Bauqualität Stadt A (reich)',
},
buildingQualityB: {
min: 0, max: 1, default: 0.2,
unit: '', label: 'Bauqualität Stadt B (arm)',
},
}
}
/** Simuliert einen Tick (= 1 Jahr) */
tick(): QuakeEvent | null {
this.tickCount++
const speed = this.getVariable('plateSpeed')
const hardness = this.getVariable('rockHardness')
// Spannung baut sich auf
this.stress += speed * 0.1 * hardness
// Bruchspannung (mit Zufallskomponente)
const breakThreshold = 3 + Math.random() * 2
if (this.stress >= breakThreshold) {
const magnitude = computeMagnitude(this.stress, hardness)
const quake: QuakeEvent = {
time: this.tickCount,
magnitude,
epicenterX: 0.5 + (Math.random() - 0.5) * 0.2,
depth: 5 + Math.random() * 50,
}
this.quakeHistory.push(quake)
this.stress = this.stress * 0.1 // Spannungsabbau (nicht komplett)
return quake
}
return null
}
getStress(): number { return this.stress; }
getHistory(): QuakeEvent[] { return [...this.quakeHistory]; }
/** Vergleicht Auswirkungen auf beide Städte */
compareImpact(magnitude: number): { cityA: CityImpact; cityB: CityImpact } {
const qualA = this.getVariable('buildingQualityA')
const qualB = this.getVariable('buildingQualityB')
const cityA = computeCityImpact(magnitude, 30, qualA)
cityA.name = 'Stadt A (wohlhabend)'
cityA.type = 'rich'
const cityB = computeCityImpact(magnitude, 30, qualB)
cityB.name = 'Stadt B (einkommensschwach)'
cityB.type = 'poor'
this.state.results = { cityA, cityB, magnitude }
return { cityA, cityB }
}
compute() {
return {
stress: this.stress,
quakeCount: this.quakeHistory.length,
lastMagnitude: this.quakeHistory.length > 0
? this.quakeHistory[this.quakeHistory.length - 1].magnitude
: 0,
}
}
protected onVariableChange(): void {}
}
+467
View File
@@ -0,0 +1,467 @@
/**
* SIM-07: Erdbeben-Simulator — Canvas Renderer
*
* Visualisiert:
* - Querschnitt der Erdkruste mit zwei tektonischen Platten
* - Spannungsaufbau (visuell durch Risse/Verformung)
* - Erdbeben-Welle bei Bruch
* - Zwei Städte auf der Oberfläche (links arm, rechts reich)
* - Schadensanzeige bei Beben
*
* Stil: Skandinavisch — gedeckte Erdfarben, klare Schichten
*/
import { ErdbebenSimulation, computeCityImpact, type QuakeEvent } from './logic'
interface SeismicWave {
x: number
y: number
radius: number
maxRadius: number
intensity: number
}
interface DamageMarker {
x: number
y: number
damage: number
age: number
}
export class ErdbebenRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private sim: ErdbebenSimulation
private W = 0
private H = 0
private t = 0
private animId = 0
private waves: SeismicWave[] = []
private damageA: DamageMarker[] = []
private damageB: DamageMarker[] = []
private shake = 0
private autoTick = true
private tickAccum = 0
private lastQuake: QuakeEvent | null = null
private lastImpact: ReturnType<ErdbebenSimulation['compareImpact']> | null = null
// Layout (relative to H)
private surfaceY = 0
private mantleY = 0
private cityAX = 0
private cityBX = 0
// Plate offsets (visual deformation)
private plateLeftX = 0
private plateRightX = 0
private col = {
sky: '#dde3da',
skyTop: '#c8d4c6',
crust: '#a89878',
crustDark: '#8a7858',
mantle: '#c07a6b',
mantleHot: '#d08a7b',
line: '#5a5a5a',
cityRich: '#5a8a5e',
cityPoor: '#c4a35a',
wave: '#c07a6b',
text: '#1a1a1a',
muted: '#6a6a6a',
}
constructor(container: HTMLElement, sim: ErdbebenSimulation) {
this.sim = sim
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.65, 500)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
this.surfaceY = this.H * 0.45
this.mantleY = this.H * 0.85
this.cityAX = this.W * 0.25
this.cityBX = this.W * 0.75
}
start(): void {
const loop = () => {
this.t += 0.016
this.update()
this.draw()
this.animId = requestAnimationFrame(loop)
}
loop()
}
stop(): void {
cancelAnimationFrame(this.animId)
}
triggerManualQuake(): void {
// Force-tick until quake
for (let i = 0; i < 200; i++) {
const q = this.sim.tick()
if (q) {
this.spawnQuake(q)
return
}
}
}
private update(): void {
// Auto-tick
if (this.autoTick) {
this.tickAccum += 0.016
if (this.tickAccum > 0.3) { // Tick every 0.3s
this.tickAccum = 0
const q = this.sim.tick()
if (q) this.spawnQuake(q)
}
}
// Plate visual deformation based on stress
const stress = this.sim.getStress()
const targetOffset = Math.min(8, stress * 1.5)
this.plateLeftX += (-targetOffset - this.plateLeftX) * 0.05
this.plateRightX += (targetOffset - this.plateRightX) * 0.05
// Update waves
for (let i = this.waves.length - 1; i >= 0; i--) {
const w = this.waves[i]
w.radius += 4
w.intensity *= 0.97
if (w.radius > w.maxRadius) this.waves.splice(i, 1)
}
// Shake decay
this.shake *= 0.92
}
private spawnQuake(q: QuakeEvent): void {
this.lastQuake = q
const epicenterX = q.epicenterX * this.W
this.waves.push({
x: epicenterX,
y: this.surfaceY + 20,
radius: 5,
maxRadius: this.W * 0.8,
intensity: 1,
})
this.shake = q.magnitude * 0.5
// Reset plate visual deformation
this.plateLeftX = 0
this.plateRightX = 0
// Compute city impacts
this.lastImpact = this.sim.compareImpact(q.magnitude)
// Add damage markers
this.damageA.push({
x: this.cityAX,
y: this.surfaceY,
damage: this.lastImpact.cityA.damage,
age: 0,
})
this.damageB.push({
x: this.cityBX,
y: this.surfaceY,
damage: this.lastImpact.cityB.damage,
age: 0,
})
// Keep last 3 markers
if (this.damageA.length > 3) this.damageA.shift()
if (this.damageB.length > 3) this.damageB.shift()
}
private draw(): void {
const { ctx, W, H } = this
// Apply shake
ctx.save()
if (this.shake > 0.01) {
ctx.translate((Math.random() - 0.5) * this.shake, (Math.random() - 0.5) * this.shake)
}
ctx.clearRect(-10, -10, W + 20, H + 20)
// Sky
const sky = ctx.createLinearGradient(0, 0, 0, this.surfaceY)
sky.addColorStop(0, this.col.skyTop)
sky.addColorStop(1, this.col.sky)
ctx.fillStyle = sky
ctx.fillRect(0, 0, W, this.surfaceY)
// Sun
ctx.fillStyle = '#e8c84a'
ctx.beginPath()
ctx.arc(W * 0.85, this.H * 0.12, 18, 0, Math.PI * 2)
ctx.fill()
// Earth crust (left plate)
ctx.save()
ctx.translate(this.plateLeftX, 0)
ctx.fillStyle = this.col.crust
ctx.fillRect(-20, this.surfaceY, W * 0.5 + 20, this.mantleY - this.surfaceY)
ctx.fillStyle = this.col.crustDark
ctx.fillRect(-20, this.mantleY - 8, W * 0.5 + 20, 8)
// Surface texture
ctx.fillStyle = this.col.crustDark
for (let x = 0; x < W * 0.5; x += 25) {
ctx.fillRect(x + 2, this.surfaceY, 1, 6 + Math.sin(x * 0.1) * 3)
}
ctx.restore()
// Right plate
ctx.save()
ctx.translate(this.plateRightX, 0)
ctx.fillStyle = this.col.crust
ctx.fillRect(W * 0.5 - 5, this.surfaceY, W * 0.5 + 20, this.mantleY - this.surfaceY)
ctx.fillStyle = this.col.crustDark
ctx.fillRect(W * 0.5 - 5, this.mantleY - 8, W * 0.5 + 20, 8)
for (let x = W * 0.5; x < W; x += 25) {
ctx.fillRect(x + 2, this.surfaceY, 1, 6 + Math.sin(x * 0.1) * 3)
}
ctx.restore()
// Plate boundary line (red, intensity = stress)
const stress = this.sim.getStress()
const boundaryAlpha = Math.min(0.8, stress * 0.15)
ctx.strokeStyle = `rgba(192,80,60,${boundaryAlpha})`
ctx.lineWidth = 2 + stress * 0.5
ctx.beginPath()
ctx.moveTo(W * 0.5, this.surfaceY)
ctx.lineTo(W * 0.5, this.mantleY)
ctx.stroke()
// Mantle
const mantleGrad = ctx.createLinearGradient(0, this.mantleY, 0, H)
mantleGrad.addColorStop(0, this.col.mantle)
mantleGrad.addColorStop(1, this.col.mantleHot)
ctx.fillStyle = mantleGrad
ctx.fillRect(0, this.mantleY, W, H - this.mantleY)
// Magma blobs (animated)
for (let i = 0; i < 5; i++) {
const mx = (i / 5) * W + Math.sin(this.t + i) * 20
const my = this.mantleY + 15 + Math.sin(this.t * 0.5 + i * 2) * 5
ctx.fillStyle = `rgba(232,140,120,${0.3 + Math.sin(this.t + i) * 0.2})`
ctx.beginPath()
ctx.arc(mx, my, 8 + Math.sin(this.t * 2 + i) * 2, 0, Math.PI * 2)
ctx.fill()
}
// Plate movement arrows
if (stress > 0.5) {
this.drawArrow(ctx, W * 0.15, this.mantleY - 25, 'right', stress * 0.3)
this.drawArrow(ctx, W * 0.85, this.mantleY - 25, 'left', stress * 0.3)
}
// Cities
this.drawCity(ctx, this.cityAX, this.surfaceY, 'rich', this.damageA[this.damageA.length - 1])
this.drawCity(ctx, this.cityBX, this.surfaceY, 'poor', this.damageB[this.damageB.length - 1])
// City labels
ctx.fillStyle = this.col.text
ctx.font = 'bold 11px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Stadt A', this.cityAX, this.surfaceY - 35)
ctx.font = '9px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText('(wohlhabend)', this.cityAX, this.surfaceY - 24)
ctx.fillStyle = this.col.text
ctx.font = 'bold 11px Inter, sans-serif'
ctx.fillText('Stadt B', this.cityBX, this.surfaceY - 35)
ctx.font = '9px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText('(einkommensschwach)', this.cityBX, this.surfaceY - 24)
// Seismic waves
for (const w of this.waves) {
ctx.strokeStyle = `rgba(192,122,107,${w.intensity * 0.7})`
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(w.x, w.y, w.radius, 0, Math.PI * 2)
ctx.stroke()
// Inner waves
ctx.lineWidth = 1
ctx.strokeStyle = `rgba(192,122,107,${w.intensity * 0.4})`
ctx.beginPath()
ctx.arc(w.x, w.y, w.radius * 0.7, 0, Math.PI * 2)
ctx.stroke()
}
ctx.restore() // shake
// ── Top info bar ──
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fillRect(0, 0, W, 38)
ctx.fillStyle = this.col.text
ctx.font = 'bold 12px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`Spannung: ${stress.toFixed(1)}`, 12, 17)
// Stress bar
const barX = 105, barY = 8, barW = 80, barH = 8
ctx.fillStyle = '#e0ddd6'
ctx.beginPath()
ctx.roundRect(barX, barY, barW, barH, 4)
ctx.fill()
const fillW = Math.min(barW, (stress / 5) * barW)
ctx.fillStyle = stress > 4 ? '#c0503c' : stress > 2.5 ? '#c4a35a' : '#5a8a5e'
ctx.beginPath()
ctx.roundRect(barX, barY, fillW, barH, 4)
ctx.fill()
ctx.font = '11px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText(`Beben: ${this.sim.getHistory().length}`, 200, 17)
if (this.lastQuake) {
ctx.fillStyle = this.col.text
ctx.font = 'bold 11px Inter, sans-serif'
ctx.fillText(`Letztes Beben: ${this.lastQuake.magnitude.toFixed(1)} M`, 280, 17)
}
// Bottom comparison panel (when impact computed)
if (this.lastImpact) {
const panelY = H - 60
ctx.fillStyle = 'rgba(255,255,255,0.92)'
ctx.fillRect(0, panelY, W, 60)
// City A
ctx.fillStyle = this.col.cityRich
ctx.font = 'bold 11px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`Stadt A: ${this.lastImpact.cityA.damage}% Schaden`, 15, panelY + 20)
ctx.fillStyle = this.col.muted
ctx.font = '10px Inter, sans-serif'
ctx.fillText(`${this.lastImpact.cityA.casualties} Opfer · Wiederaufbau: ${this.lastImpact.cityA.recovery}`, 15, panelY + 38)
// City B
ctx.fillStyle = this.col.cityPoor
ctx.font = 'bold 11px Inter, sans-serif'
ctx.textAlign = 'right'
ctx.fillText(`Stadt B: ${this.lastImpact.cityB.damage}% Schaden`, W - 15, panelY + 20)
ctx.fillStyle = this.col.muted
ctx.font = '10px Inter, sans-serif'
ctx.fillText(`${this.lastImpact.cityB.casualties} Opfer · Wiederaufbau: ${this.lastImpact.cityB.recovery}`, W - 15, panelY + 38)
}
}
private drawArrow(ctx: CanvasRenderingContext2D, x: number, y: number, dir: 'left' | 'right', intensity: number): void {
ctx.save()
ctx.globalAlpha = Math.min(1, intensity)
ctx.fillStyle = '#c0503c'
ctx.strokeStyle = '#c0503c'
ctx.lineWidth = 2
const len = 25
const sign = dir === 'right' ? 1 : -1
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x + len * sign, y)
ctx.stroke()
// Arrow head
ctx.beginPath()
ctx.moveTo(x + len * sign, y)
ctx.lineTo(x + (len - 6) * sign, y - 5)
ctx.lineTo(x + (len - 6) * sign, y + 5)
ctx.closePath()
ctx.fill()
ctx.restore()
}
private drawCity(ctx: CanvasRenderingContext2D, x: number, y: number, type: 'rich' | 'poor', damage?: DamageMarker): void {
const dmg = damage ? damage.damage / 100 : 0
const isRich = type === 'rich'
if (isRich) {
// Wohlhabende Stadt — moderne Hochhäuser
const buildings = [
{ offX: -20, w: 8, h: 22 },
{ offX: -10, w: 10, h: 30 },
{ offX: 2, w: 12, h: 35 },
{ offX: 16, w: 8, h: 25 },
]
for (const b of buildings) {
const collapseRatio = Math.max(0, 1 - dmg * 0.6)
const actualH = b.h * collapseRatio
// Tilt if damaged
const tilt = dmg * (Math.random() - 0.5) * 0.15
ctx.save()
ctx.translate(x + b.offX, y)
ctx.rotate(tilt)
ctx.fillStyle = dmg > 0.3 ? '#9a8a7a' : '#c0d0c8'
ctx.fillRect(0, -actualH, b.w, actualH)
// Windows
if (dmg < 0.5) {
ctx.fillStyle = '#5a8a8e'
for (let wy = 4; wy < actualH - 2; wy += 6) {
for (let wx = 1; wx < b.w - 2; wx += 4) {
ctx.fillRect(wx, -actualH + wy, 2, 3)
}
}
}
ctx.restore()
}
} else {
// Einkommensschwache Stadt — kleine, einfache Häuser
const houses = [
{ offX: -22, w: 10, h: 12 },
{ offX: -10, w: 11, h: 14 },
{ offX: 2, w: 12, h: 13 },
{ offX: 15, w: 9, h: 10 },
{ offX: 25, w: 8, h: 11 },
]
for (const h of houses) {
const collapseRatio = Math.max(0.1, 1 - dmg * 0.95)
const actualH = h.h * collapseRatio
const tilt = dmg * (Math.random() - 0.5) * 0.4
ctx.save()
ctx.translate(x + h.offX, y)
ctx.rotate(tilt)
ctx.fillStyle = dmg > 0.4 ? '#8a7a6a' : '#d8c8a8'
ctx.fillRect(0, -actualH, h.w, actualH)
// Roof (only if not too damaged)
if (dmg < 0.5) {
ctx.fillStyle = '#a06050'
ctx.beginPath()
ctx.moveTo(-1, -actualH)
ctx.lineTo(h.w / 2, -actualH - 4)
ctx.lineTo(h.w + 1, -actualH)
ctx.closePath()
ctx.fill()
}
ctx.restore()
// Rubble
if (dmg > 0.3) {
ctx.fillStyle = '#7a6a5a'
ctx.fillRect(x + h.offX - 2, y - 2, h.w + 4, 2)
}
}
}
}
}
@@ -0,0 +1,587 @@
/**
* Energiemix-Spiel — Canvas Renderer
*
* Ansicht: Weite Landschaft mit Stadt am rechten Rand, dazwischen
* verschiedene Kraftwerke in deterministischen Positionen. Im Hintergrund
* Berge. Über der Landschaft zieht Wetter (Wolken, Sonne).
*
* Animationen:
* - Windräder drehen sich (Geschwindigkeit abhängig von Wind-Faktor)
* - Solar-Panels glitzern
* - Rauch aus Kohle/Gas-Schornsteinen (pulsiert mit Auslastung)
* - Kühltürme mit Dampf
* - Batteriespeicher zeigen Pulsieren
* - Bei Blackout: Stadt wird dunkel
*
* Zeitleiste am unteren Rand mit Jahren und Blackout-Markern.
*/
import { EnergiemixGame, PLANT_TYPES } from './game'
interface PlacedPlant {
typeId: string
x: number // 0..1 in Welt
y: number // 0..1 vertikal
scale: number
ownerId: string
builtTick: number
}
export class EnergiemixRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private game: EnergiemixGame
private W = 0
private H = 0
private t = 0
private animId = 0
private placed: PlacedPlant[] = []
private knownIds = new Set<string>()
private lastBlackouts = 0
private blackoutFlash = 0
constructor(container: HTMLElement, game: EnergiemixGame) {
this.game = game
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dce6ea;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.55, 440)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
private seededRand(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
return x - Math.floor(x)
}
start(): void {
let lastFrame = performance.now()
const loop = (now: number) => {
const dt = (now - lastFrame) / 1000
lastFrame = now
this.t += dt
this.updateScene()
this.draw()
this.animId = requestAnimationFrame(loop)
}
this.animId = requestAnimationFrame(loop)
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private updateScene(): void {
const owned = this.game.getOwnedPlants()
for (const p of owned) {
for (let i = 0; i < p.count; i++) {
const id = `${p.typeId}-${i}`
if (!this.knownIds.has(id)) {
this.knownIds.add(id)
this.placed.push(this.placePlant(p.typeId, i))
}
}
}
// Blackout-Flash
const blackouts = this.game.getResource('blackouts')
if (blackouts > this.lastBlackouts) {
this.blackoutFlash = 1.0
this.lastBlackouts = blackouts
}
this.blackoutFlash = Math.max(0, this.blackoutFlash - 0.012)
}
private placePlant(typeId: string, index: number): PlacedPlant {
// Stable deterministic placement by type+index.
// Different plant types get different "zones":
const seed = typeId.charCodeAt(0) * 97 + typeId.charCodeAt(1) * 13 + index * 41
let xZone: [number, number] = [0.08, 0.68]
let yZone: [number, number] = [0.48, 0.62]
switch (typeId) {
case 'wind':
xZone = [0.05, 0.58]
yZone = [0.25, 0.45]
break
case 'solar':
xZone = [0.12, 0.60]
yZone = [0.55, 0.68]
break
case 'hydro':
xZone = [0.02, 0.18]
yZone = [0.50, 0.62]
break
case 'coal':
case 'gas':
xZone = [0.18, 0.52]
yZone = [0.45, 0.58]
break
case 'biomass':
xZone = [0.25, 0.58]
yZone = [0.52, 0.62]
break
case 'nuclear':
xZone = [0.32, 0.50]
yZone = [0.44, 0.56]
break
case 'battery':
xZone = [0.55, 0.72]
yZone = [0.60, 0.70]
break
}
const rx = this.seededRand(seed)
const ry = this.seededRand(seed + 7)
return {
typeId,
x: xZone[0] + rx * (xZone[1] - xZone[0]),
y: yZone[0] + ry * (yZone[1] - yZone[0]),
scale: 0.9 + this.seededRand(seed + 13) * 0.25,
ownerId: `${typeId}-${index}`,
builtTick: this.game.getSnapshot().tick,
}
}
private draw(): void {
const ctx = this.ctx
const W = this.W
const H = this.H
const tick = this.game.getSnapshot().tick
const year = this.game.getStartYear() + tick
const weather = this.game.getWeatherFactors()
// Fortschritt 0..1 über die Spielzeit
const progress = Math.min(1, tick / 25)
// Himmel wird mit zunehmender Erneuerbar-Quote klarer
const renewable = this.game.getResource('renewable') / 100
// === HIMMEL ===
const sky = ctx.createLinearGradient(0, 0, 0, H * 0.7)
const smog = 0.3 * (1 - renewable)
sky.addColorStop(0, `rgb(${180 + smog * 20}, ${205 + smog * 10}, ${225 - smog * 20})`)
sky.addColorStop(1, `rgb(${220 + smog * 10}, ${230}, ${225 - smog * 10})`)
ctx.fillStyle = sky
ctx.fillRect(0, 0, W, H * 0.72)
// === SONNE ===
const sunX = W * 0.82
const sunY = H * 0.15
const sunR = 22 * (0.8 + weather.solar * 0.3)
const sunAlpha = Math.max(0.3, weather.solar)
ctx.fillStyle = `rgba(250, 220, 140, ${sunAlpha})`
ctx.beginPath()
ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = `rgba(250, 240, 200, ${sunAlpha * 0.3})`
ctx.beginPath()
ctx.arc(sunX, sunY, sunR * 1.6, 0, Math.PI * 2)
ctx.fill()
// === WOLKEN (verstärkt bei dunkler Solar-Faktor) ===
const cloudCount = Math.round(3 + (1 - weather.solar) * 4)
for (let i = 0; i < cloudCount; i++) {
const cx = ((this.t * 6 + i * W / cloudCount) % (W + 80)) - 40
const cy = H * 0.10 + i * 12
this.drawCloud(cx, cy, 30 + i * 6, 0.75)
}
// === BERGE HINTEN ===
ctx.fillStyle = '#8aa0a8'
ctx.beginPath()
ctx.moveTo(0, H * 0.52)
for (let x = 0; x <= W; x += 40) {
const s = Math.sin(x * 0.013 + 2) * 22 + Math.sin(x * 0.031) * 10
ctx.lineTo(x, H * 0.52 + s)
}
ctx.lineTo(W, H * 0.72)
ctx.lineTo(0, H * 0.72)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#9fb2ba'
ctx.beginPath()
ctx.moveTo(0, H * 0.58)
for (let x = 0; x <= W; x += 30) {
const s = Math.sin(x * 0.019 + 4) * 18
ctx.lineTo(x, H * 0.58 + s)
}
ctx.lineTo(W, H * 0.72)
ctx.lineTo(0, H * 0.72)
ctx.closePath()
ctx.fill()
// === BODEN ===
const ground = ctx.createLinearGradient(0, H * 0.68, 0, H * 0.92)
// Farbe wird mit Erneuerbar-Quote grüner
const green = 140 + renewable * 30
ground.addColorStop(0, `rgb(140, ${green}, 110)`)
ground.addColorStop(1, `rgb(110, ${green - 20}, 90)`)
ctx.fillStyle = ground
ctx.fillRect(0, H * 0.68, W, H * 0.24)
// === FLUSS am linken Rand (für Wasserkraft) ===
ctx.fillStyle = `rgba(74, 124, 138, ${0.75 + weather.hydro * 0.2})`
ctx.beginPath()
ctx.moveTo(0, H * 0.70)
ctx.quadraticCurveTo(W * 0.05, H * 0.80, W * 0.12, H * 0.85)
ctx.lineTo(W * 0.14, H * 0.89)
ctx.lineTo(0, H * 0.89)
ctx.closePath()
ctx.fill()
// === STADT RECHTS ===
this.drawCity(W * 0.75, H * 0.70, W * 0.22, H * 0.16, tick, this.blackoutFlash)
// === KRAFTWERKE ===
// Sortiere nach x für korrekte Tiefenordnung
const sorted = [...this.placed].sort((a, b) => a.y - b.y)
for (const p of sorted) {
const px = p.x * W
const py = p.y * H
this.drawPlant(p, px, py, weather)
}
// === HUD ===
this.drawHUD(year, tick)
// === BLACKOUT-FLASH OVERLAY ===
if (this.blackoutFlash > 0) {
ctx.fillStyle = `rgba(40, 10, 10, ${this.blackoutFlash * 0.5})`
ctx.fillRect(0, 0, W, H * 0.88)
}
// === ZEITLEISTE UNTEN ===
this.drawTimeline()
}
private drawCloud(cx: number, cy: number, r: number, alpha: number): void {
const ctx = this.ctx
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
ctx.beginPath()
ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2)
ctx.arc(cx + r * 0.5, cy - 4, r * 0.45, 0, Math.PI * 2)
ctx.arc(cx + r * 0.9, cy, r * 0.5, 0, Math.PI * 2)
ctx.arc(cx + r * 0.4, cy + 5, r * 0.45, 0, Math.PI * 2)
ctx.fill()
}
private drawCity(x: number, baseY: number, w: number, h: number, _tick: number, blackout: number): void {
const ctx = this.ctx
// Gebäude-Silhouetten (6 Türme)
const towers = 6
const litProbability = Math.max(0.1, 1 - blackout)
for (let i = 0; i < towers; i++) {
const seed = i * 11 + 3
const r = this.seededRand(seed)
const tw = (w / towers) * 0.8
const th = h * (0.6 + r * 0.5)
const tx = x + i * (w / towers)
const ty = baseY - th
ctx.fillStyle = '#4a4a55'
ctx.fillRect(tx, ty, tw, th)
// Fenster
for (let wy = ty + 4; wy < baseY - 3; wy += 6) {
for (let wx = tx + 2; wx < tx + tw - 2; wx += 5) {
const on = this.seededRand(seed * 77 + wy * 5 + wx) < litProbability * 0.55
ctx.fillStyle = on ? 'rgba(255, 220, 140, .9)' : 'rgba(80, 80, 95, .6)'
ctx.fillRect(wx, wy, 2, 2)
}
}
}
}
private drawPlant(p: PlacedPlant, x: number, y: number, weather: { wind: number; solar: number; hydro: number }): void {
const ctx = this.ctx
const s = p.scale
switch (p.typeId) {
case 'coal':
this.drawCoalPlant(x, y, s)
break
case 'gas':
this.drawGasPlant(x, y, s)
break
case 'hydro':
this.drawHydroPlant(x, y, s)
break
case 'wind':
this.drawWindTurbine(x, y, s, weather.wind)
break
case 'solar':
this.drawSolarPanel(x, y, s)
break
case 'biomass':
this.drawBiomassPlant(x, y, s)
break
case 'nuclear':
this.drawNuclearPlant(x, y, s)
break
case 'battery':
this.drawBattery(x, y, s)
break
}
ctx.globalAlpha = 1
}
private drawCoalPlant(x: number, y: number, s: number): void {
const ctx = this.ctx
// Haupthalle
ctx.fillStyle = '#6a6055'
ctx.fillRect(x - 18 * s, y - 14 * s, 36 * s, 18 * s)
ctx.fillStyle = '#4a4038'
ctx.fillRect(x - 18 * s, y - 14 * s, 36 * s, 2 * s)
// Schornsteine
ctx.fillStyle = '#8a7868'
ctx.fillRect(x - 12 * s, y - 30 * s, 5 * s, 18 * s)
ctx.fillRect(x + 6 * s, y - 30 * s, 5 * s, 18 * s)
// Rauch
this.drawSmoke(x - 9 * s, y - 30 * s, 1.0, '#7a6a58')
this.drawSmoke(x + 9 * s, y - 30 * s, 0.9, '#7a6a58')
}
private drawGasPlant(x: number, y: number, s: number): void {
const ctx = this.ctx
ctx.fillStyle = '#5a7a8a'
ctx.fillRect(x - 14 * s, y - 10 * s, 28 * s, 14 * s)
// Tank
ctx.fillStyle = '#aabdc5'
ctx.beginPath()
ctx.arc(x - 8 * s, y - 2 * s, 6 * s, 0, Math.PI * 2)
ctx.fill()
// Schornstein
ctx.fillStyle = '#7a8a95'
ctx.fillRect(x + 6 * s, y - 22 * s, 3 * s, 14 * s)
this.drawSmoke(x + 7 * s, y - 22 * s, 0.55, '#dddacc')
}
private drawHydroPlant(x: number, y: number, s: number): void {
const ctx = this.ctx
// Staumauer
ctx.fillStyle = '#999a9c'
ctx.fillRect(x - 12 * s, y - 18 * s, 24 * s, 22 * s)
// Abflussöffnung
ctx.fillStyle = '#3a5a6a'
ctx.fillRect(x - 3 * s, y - 4 * s, 6 * s, 8 * s)
// Wasserstrahl
ctx.fillStyle = 'rgba(200, 230, 240, 0.7)'
ctx.beginPath()
ctx.moveTo(x - 3 * s, y + 4 * s)
ctx.lineTo(x + 3 * s, y + 4 * s)
ctx.lineTo(x + 8 * s, y + 12 * s)
ctx.lineTo(x - 8 * s, y + 12 * s)
ctx.closePath()
ctx.fill()
}
private drawWindTurbine(x: number, y: number, s: number, windFactor: number): void {
const ctx = this.ctx
// Mast
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(x - 1.2 * s, y - 30 * s, 2.4 * s, 44 * s)
// Nabe
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
ctx.arc(x, y - 30 * s, 2.5 * s, 0, Math.PI * 2)
ctx.fill()
// Rotor — dreht sich mit Windgeschwindigkeit
const rot = this.t * windFactor * 1.8
for (let i = 0; i < 3; i++) {
const a = rot + (i * Math.PI * 2) / 3
ctx.save()
ctx.translate(x, y - 30 * s)
ctx.rotate(a)
ctx.fillStyle = '#fafafa'
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(1.2 * s, -14 * s)
ctx.lineTo(-1.2 * s, -14 * s)
ctx.closePath()
ctx.fill()
ctx.restore()
}
}
private drawSolarPanel(x: number, y: number, s: number): void {
const ctx = this.ctx
// 3 Panels in Reihe
for (let i = 0; i < 3; i++) {
const px = x + (i - 1) * 9 * s
ctx.save()
ctx.translate(px, y)
// leichte Neigung
ctx.transform(1, 0, -0.35, 0.82, 0, 0)
ctx.fillStyle = '#1e3a5a'
ctx.fillRect(-6 * s, -3 * s, 12 * s, 6 * s)
// Gitter
ctx.strokeStyle = '#3a5a7a'
ctx.lineWidth = 0.5
ctx.beginPath()
for (let g = -5; g <= 5; g += 2) {
ctx.moveTo(g * s, -3 * s)
ctx.lineTo(g * s, 3 * s)
}
ctx.stroke()
ctx.restore()
// Ständer
ctx.fillStyle = '#666'
ctx.fillRect(px - 0.5, y + 2 * s, 1, 4 * s)
}
}
private drawBiomassPlant(x: number, y: number, s: number): void {
const ctx = this.ctx
ctx.fillStyle = '#7a5a3a'
ctx.fillRect(x - 13 * s, y - 10 * s, 26 * s, 14 * s)
// Holzstapel
ctx.fillStyle = '#8a6a4a'
ctx.fillRect(x - 16 * s, y + 1 * s, 6 * s, 3 * s)
ctx.fillRect(x - 16 * s, y - 2 * s, 6 * s, 3 * s)
// Grüner Schornstein
ctx.fillStyle = '#6a7a5a'
ctx.fillRect(x + 5 * s, y - 22 * s, 3 * s, 14 * s)
this.drawSmoke(x + 6.5 * s, y - 22 * s, 0.45, '#e0e0d0')
}
private drawNuclearPlant(x: number, y: number, s: number): void {
const ctx = this.ctx
// Reaktorkuppel
ctx.fillStyle = '#c0c5c8'
ctx.beginPath()
ctx.arc(x - 10 * s, y - 4 * s, 8 * s, Math.PI, Math.PI * 2)
ctx.fill()
ctx.fillRect(x - 18 * s, y - 4 * s, 16 * s, 8 * s)
// Kühlturm
ctx.fillStyle = '#b0b5b8'
ctx.beginPath()
ctx.moveTo(x + 3 * s, y - 28 * s)
ctx.lineTo(x + 14 * s, y - 28 * s)
ctx.lineTo(x + 18 * s, y + 4 * s)
ctx.lineTo(x - 1 * s, y + 4 * s)
ctx.closePath()
ctx.fill()
// Dampf
this.drawSmoke(x + 8.5 * s, y - 28 * s, 1.3, '#ffffff')
}
private drawBattery(x: number, y: number, s: number): void {
const ctx = this.ctx
// Container
ctx.fillStyle = '#e8e8e8'
ctx.fillRect(x - 12 * s, y - 8 * s, 24 * s, 14 * s)
ctx.strokeStyle = '#888'
ctx.lineWidth = 1
ctx.strokeRect(x - 12 * s, y - 8 * s, 24 * s, 14 * s)
// LEDs pulsieren
const pulse = (Math.sin(this.t * 2) + 1) / 2
ctx.fillStyle = `rgba(90, 200, 120, ${0.5 + pulse * 0.5})`
for (let i = 0; i < 5; i++) {
ctx.fillRect(x - 10 * s + i * 5 * s, y - 5 * s, 3 * s, 2 * s)
}
// Blitzsymbol
ctx.fillStyle = '#d4a050'
ctx.font = `bold ${7 * s}px sans-serif`
ctx.textAlign = 'center'
ctx.fillText('⚡', x, y + 4 * s)
}
private drawSmoke(x: number, y: number, intensity: number, color: string): void {
const ctx = this.ctx
for (let i = 0; i < 3; i++) {
const off = (this.t * 8 + i * 6) % 18
const alpha = Math.max(0, (1 - off / 18) * intensity * 0.7)
ctx.fillStyle = color.startsWith('#') ? this.hexWithAlpha(color, alpha) : color
ctx.globalAlpha = alpha
ctx.beginPath()
ctx.arc(x + Math.sin(off * 0.3) * 3, y - off, 3 + off * 0.25, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
private hexWithAlpha(hex: string, _a: number): string {
return hex
}
private drawHUD(year: number, _tick: number): void {
const ctx = this.ctx
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fillRect(10, 10, 108, 36)
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
ctx.lineWidth = 1
ctx.strokeRect(10, 10, 108, 36)
ctx.fillStyle = '#2a2a2a'
ctx.font = 'bold 15px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(`${year}`, 18, 28)
ctx.fillStyle = '#6a6a6a'
ctx.font = '9px Inter, sans-serif'
const renewable = Math.round(this.game.getResource('renewable'))
ctx.fillText(`🌱 ${renewable} % erneuerbar`, 18, 40)
}
private drawTimeline(): void {
const ctx = this.ctx
const W = this.W
const H = this.H
const tlY = H - 28
const tlH = 22
ctx.fillStyle = 'rgba(255,255,255,0.95)'
ctx.fillRect(0, tlY, W, tlH)
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
ctx.beginPath()
ctx.moveTo(0, tlY + 0.5)
ctx.lineTo(W, tlY + 0.5)
ctx.stroke()
const maxTicks = 25
const tick = this.game.getSnapshot().tick
const startYear = this.game.getStartYear()
// Jahre-Marker alle 5 Jahre
ctx.fillStyle = '#8a8a8a'
ctx.font = '9px Inter, sans-serif'
ctx.textAlign = 'center'
for (let y = 0; y <= maxTicks; y += 5) {
const x = (y / maxTicks) * W
ctx.fillRect(x - 0.5, tlY + 2, 1, 5)
ctx.fillText(`${startYear + y}`, x, tlY + 18)
}
// Aktueller Tick als Punkt
const curX = (tick / maxTicks) * W
ctx.fillStyle = '#4a7c8a'
ctx.beginPath()
ctx.arc(curX, tlY + 10, 4, 0, Math.PI * 2)
ctx.fill()
// Blackout-Marker
const events = this.game.getSnapshot().events
for (const e of events) {
if (e.type === 'blackout' || e.type === 'peak-blackout') {
const ex = (e.tick / maxTicks) * W
ctx.fillStyle = '#b04a3a'
ctx.beginPath()
ctx.arc(ex, tlY + 10, 2.5, 0, Math.PI * 2)
ctx.fill()
}
}
}
}
// Re-export für Typ-Konsumierung durch UI
export type { PlantType } from './game'
+481
View File
@@ -0,0 +1,481 @@
/**
* SIM-08: Energiewende-Planer*in — Spielbare Energiemix-Simulation
*
* Du übernimmst 2025 als Energieplaner*in eine Region mit ca. 200.000
* Einwohnern. Aktuell kommt der Strom größtenteils aus fossilen Quellen.
* Bis 2050 (25 Jahre) musst du den Energiemix umbauen:
*
* - Die Nachfrage steigt (E-Autos, Wärmepumpen, Digitalisierung).
* - CO₂ muss runter — Klimaziel.
* - Blackouts dürfen nicht passieren — Versorgungssicherheit.
* - Du hast ein knappes Budget.
*
* Fachlich korrekt:
* - Kapazitätsfaktoren echt (Wind ~25%, Solar ~12%, Wasser ~45%, Kernkraft ~90%)
* - CO₂-Emissionen in gCO₂/kWh basieren auf IPCC-Median-Werten
* - Erneuerbare brauchen Speicher oder backup-fähige Partner (Gas)
*
* Kernbotschaft: Es gibt keinen Königsweg. Jede Technologie hat Stärken und
* Schwächen. Die Transformation ist ein Balance-Akt zwischen drei Zielen:
* CO₂, Kosten und Versorgungssicherheit.
*/
import { GameEngine, type GameMeta } from '@core/game-engine'
const META: GameMeta = {
id: 'sim-08',
title: 'Energiewende-Planer*in',
description: 'Führe deine Region bis 2050 in eine CO₂-neutrale, sichere Stromversorgung.',
msPerTick: 4000,
tickUnit: 'Jahr',
maxTicks: 25,
tutorialSteps: 4,
}
const START_YEAR = 2025
export interface PlantType {
id: string
name: string
emoji: string
description: string
cost: number // Baukosten in Mio. €
capacityMW: number // Nennleistung
capacityFactor: number // Anteil der Nennleistung im Jahresmittel (0..1)
co2PerKWh: number // gCO₂/kWh (IPCC Median)
upkeep: number // Mio. €/Jahr
renewable: boolean
flexible: boolean // Kann bei Bedarf hochgefahren werden (für Backup)
storage?: number // MW Speicherkapazität (reduziert Blackout-Risiko)
}
export const PLANT_TYPES: PlantType[] = [
{
id: 'coal',
name: 'Kohlekraftwerk',
emoji: '🏭',
description: 'Billig, zuverlässig, aber höchste CO₂-Emission. Gesellschaftlich umstritten.',
cost: 80,
capacityMW: 300,
capacityFactor: 0.75,
co2PerKWh: 820,
upkeep: 8,
renewable: false,
flexible: true,
},
{
id: 'gas',
name: 'Gaskraftwerk',
emoji: '⛽',
description: 'Flexibler Backup — schnell regelbar. Halbe CO₂-Emission wie Kohle.',
cost: 110,
capacityMW: 250,
capacityFactor: 0.55,
co2PerKWh: 490,
upkeep: 9,
renewable: false,
flexible: true,
},
{
id: 'hydro',
name: 'Wasserkraftwerk',
emoji: '💧',
description: 'CO₂-frei und zuverlässig. Standortgebunden, bei Dürre weniger Leistung.',
cost: 280,
capacityMW: 180,
capacityFactor: 0.45,
co2PerKWh: 24,
upkeep: 4,
renewable: true,
flexible: true,
},
{
id: 'wind',
name: 'Windpark',
emoji: '💨',
description: 'Günstig, CO₂-arm. Wetterabhängig: liefert nur bei Wind (~25% der Zeit).',
cost: 120,
capacityMW: 200,
capacityFactor: 0.28,
co2PerKWh: 11,
upkeep: 5,
renewable: true,
flexible: false,
},
{
id: 'solar',
name: 'Solarpark',
emoji: '☀️',
description: 'Sehr günstig, CO₂-arm. Nur tagsüber, bei Bewölkung weniger.',
cost: 70,
capacityMW: 150,
capacityFactor: 0.13,
co2PerKWh: 48,
upkeep: 3,
renewable: true,
flexible: false,
},
{
id: 'biomass',
name: 'Biomasse-Heizkraftwerk',
emoji: '🌿',
description: 'Aus Holz und Agrarresten. Flexibel, nahezu CO₂-neutral.',
cost: 140,
capacityMW: 80,
capacityFactor: 0.65,
co2PerKWh: 230,
upkeep: 7,
renewable: true,
flexible: true,
},
{
id: 'nuclear',
name: 'Kernkraftwerk',
emoji: '☢️',
description: 'CO₂-frei, extrem hohe Grundlast-Leistung. Hohe Baukosten, politisch umstritten.',
cost: 600,
capacityMW: 1000,
capacityFactor: 0.90,
co2PerKWh: 12,
upkeep: 18,
renewable: false,
flexible: false,
},
{
id: 'battery',
name: 'Batteriespeicher',
emoji: '🔋',
description: 'Erzeugt keinen Strom, glättet aber Schwankungen und verhindert Blackouts.',
cost: 150,
capacityMW: 0,
capacityFactor: 0,
co2PerKWh: 0,
upkeep: 4,
renewable: true,
flexible: true,
storage: 100,
},
]
interface OwnedPlant {
typeId: string
count: number
builtTick: number
}
export class EnergiemixGame extends GameEngine {
private plants: OwnedPlant[] = []
private firedEvents = new Set<string>()
// Wetter-Modifikator dieses Jahres (ca. 0.7..1.2)
private weatherWindFactor = 1.0
private weatherSolarFactor = 1.0
private weatherHydroFactor = 1.0
constructor() {
super(META)
this.setupResources()
this.setupGoals()
this.setupTutorial()
this.setupStartingPlants()
this.recalc()
}
private setupResources(): void {
this.addResource({
id: 'budget',
name: 'Budget',
icon: '💰',
initial: 300,
unit: 'Mio €',
format: (v) => `${Math.round(v)} Mio €`,
})
this.addResource({
id: 'demand',
name: 'Strombedarf',
icon: '🔌',
initial: 700,
unit: 'MW',
format: (v) => `${Math.round(v)} MW`,
})
this.addResource({
id: 'supply',
name: 'Erzeugung (Ø)',
icon: '⚡',
initial: 0,
unit: 'MW',
format: (v) => `${Math.round(v)} MW`,
})
this.addResource({
id: 'co2',
name: 'CO₂-Ausstoß',
icon: '🌫',
initial: 0,
unit: 'kt/J',
format: (v) => `${Math.round(v)} kt/J`,
})
this.addResource({
id: 'renewable',
name: 'Erneuerbar',
icon: '🌱',
initial: 0,
unit: '%',
format: (v) => `${Math.round(v)} %`,
})
this.addResource({
id: 'blackouts',
name: 'Blackouts',
icon: '🕯',
initial: 0,
unit: '',
format: (v) => `${Math.round(v)}`,
})
}
private setupGoals(): void {
this.addGoal({
id: 'survive',
title: 'Bis 2050 planen',
description: '25 Jahre Energiewende begleiten.',
check: (g) => (g as EnergiemixGame).tick >= 25,
progress: (g) => Math.min(100, ((g as EnergiemixGame).tick / 25) * 100),
required: true,
})
this.addGoal({
id: 'renewable',
title: '80 % Erneuerbare Energie',
description: 'Mindestens 80 % des Strombedarfs aus erneuerbaren Quellen.',
check: (g) => g.getResource('renewable') >= 80,
progress: (g) => Math.min(100, (g.getResource('renewable') / 80) * 100),
required: true,
})
this.addGoal({
id: 'co2',
title: 'CO₂ unter 400 kt/Jahr',
description: 'Die Emissionen müssen deutlich sinken.',
check: (g) => g.getResource('co2') < 400,
progress: (g) => {
const co2 = g.getResource('co2')
return Math.max(0, Math.min(100, 100 - ((co2 - 400) / 10)))
},
required: true,
})
this.addGoal({
id: 'reliable',
title: 'Maximal 3 Blackouts',
description: 'Die Versorgung muss sicher bleiben.',
check: (g) => g.getResource('blackouts') <= 3,
progress: (g) => Math.max(0, Math.min(100, 100 - g.getResource('blackouts') * 25)),
required: true,
})
this.addGoal({
id: 'budget',
title: 'Nicht pleite gehen',
description: 'Budget muss positiv bleiben.',
check: (g) => g.getResource('budget') > 0,
required: true,
})
}
private setupTutorial(): void {
this.setTutorial([
{
triggerTick: 0,
title: 'Willkommen, Energieplaner*in!',
text: '2025. Du übernimmst die Energieplanung für eine Region mit ca. 200.000 Einwohner*innen.\n\nDein Auftrag: Bis 2050 (in 25 Jahren) muss der Strom CO₂-neutral, bezahlbar und zuverlässig sein.\n\nAktuell dominiert noch fossiler Strom. Der Umbau kostet Geld — aber nichts zu tun kostet das Klima.',
unlocks: ['budget'],
},
{
triggerTick: 0,
title: 'Der Energie-Mix',
text: 'Es gibt keine Lösung, die alles kann:\n\n🏭 Kohle: billig, aber hoher CO₂-Ausstoß\n⛽ Gas: flexibel, mittleres CO₂\n💧 Wasser: sauber, aber abhängig vom Niederschlag\n💨 Wind: günstig, nur bei Wind\n☀️ Solar: sehr günstig, nur bei Sonne\n🌿 Biomasse: klimaneutral, begrenzte Verfügbarkeit\n☢️ Kernkraft: CO₂-frei, teuer, umstritten\n🔋 Speicher: glättet Schwankungen',
unlocks: ['shop'],
},
{
triggerTick: 0,
title: 'Versorgungssicherheit',
text: 'Wind und Sonne liefern nicht immer.\n\nDer sogenannte Kapazitätsfaktor beschreibt, wieviel % der Nennleistung im Jahresmittel wirklich anfällt:\n- Wind ≈ 28 %\n- Solar ≈ 13 %\n- Wasser ≈ 45 %\n- Kernkraft ≈ 90 %\n\nFehlt Strom, drohen Blackouts. Backup-Kraftwerke (Gas, Biomasse) oder Speicher helfen, Lücken zu überbrücken.',
unlocks: ['supply'],
},
{
triggerTick: 0,
title: 'Der Zielkonflikt',
text: 'Du musst drei Ziele unter einen Hut bringen:\n\n🌱 CO₂: auf unter 400 kt/Jahr senken\n⚡ Versorgung: maximal 3 Blackouts bis 2050\n💰 Budget: nicht pleite gehen\n\nPro Jahr bekommst du Einnahmen aus Stromverkauf. Bau klug aus — Kraftwerke brauchen mehrere Jahre, bis sie sich rechnen.\n\nViel Erfolg! ⚡',
unlocks: ['controls'],
},
])
}
private setupStartingPlants(): void {
// Startzustand: fossil dominiert
this.plants = [
{ typeId: 'coal', count: 2, builtTick: -10 },
{ typeId: 'gas', count: 1, builtTick: -5 },
{ typeId: 'hydro', count: 1, builtTick: -20 },
]
}
private recalc(): void {
let totalMW = 0
let renewableMW = 0
let co2 = 0
let upkeep = 0
let storageMW = 0
let flexibleMW = 0
for (const p of this.plants) {
const t = PLANT_TYPES.find(x => x.id === p.typeId)
if (!t) continue
// Wetter-Modifikator anwenden
let cf = t.capacityFactor
if (t.id === 'wind') cf *= this.weatherWindFactor
else if (t.id === 'solar') cf *= this.weatherSolarFactor
else if (t.id === 'hydro') cf *= this.weatherHydroFactor
const avgMW = t.capacityMW * cf * p.count
totalMW += avgMW
if (t.renewable) renewableMW += avgMW
// CO₂: gCO₂/kWh × MW × 8760h/Jahr = gCO₂/J → kt/J
co2 += (avgMW * 8760 * t.co2PerKWh) / 1e9
upkeep += t.upkeep * p.count
if (t.storage) storageMW += t.storage * p.count
if (t.flexible) flexibleMW += t.capacityMW * p.count
}
this.setResource('supply', totalMW)
this.setResource('co2', co2)
const demand = this.getResource('demand')
this.setResource('renewable', demand > 0 ? Math.min(100, (renewableMW / demand) * 100) : 0)
this.setVariable('upkeepTotal', upkeep)
this.setVariable('storageMW', storageMW)
this.setVariable('flexibleMW', flexibleMW)
}
buyPlant(typeId: string): boolean {
const t = PLANT_TYPES.find(x => x.id === typeId)
if (!t) return false
const budget = this.getResource('budget')
if (budget < t.cost) {
this.addEvent('error', `Nicht genug Budget für ${t.name}`, 'warning')
return false
}
this.changeResource('budget', -t.cost)
const existing = this.plants.find(p => p.typeId === typeId)
if (existing) existing.count++
else this.plants.push({ typeId, count: 1, builtTick: this.tick })
this.recalc()
this.addEvent('build', `${t.emoji} ${t.name} gebaut (-${t.cost} Mio €)`, 'success')
this.notify()
return true
}
getOwnedPlants(): OwnedPlant[] { return this.plants }
getPlantCount(id: string): number {
return this.plants.find(p => p.typeId === id)?.count ?? 0
}
getStartYear(): number { return START_YEAR }
getWeatherFactors() {
return {
wind: this.weatherWindFactor,
solar: this.weatherSolarFactor,
hydro: this.weatherHydroFactor,
}
}
protected simulateTick(): void {
// 1. Nachfrage wächst (E-Mobilität, Wärmepumpen, Digitalisierung)
// Ca. 1.8 % pro Jahr
const demand = this.getResource('demand')
this.setResource('demand', demand * 1.018)
// 2. Wetter des Jahres ziehen
this.weatherWindFactor = 0.75 + Math.random() * 0.5 // 0.75..1.25
this.weatherSolarFactor = 0.85 + Math.random() * 0.3 // 0.85..1.15
this.weatherHydroFactor = 0.70 + Math.random() * 0.55 // 0.70..1.25
// 3. Neu berechnen mit aktuellem Wetter
this.recalc()
// 4. Versorgungs-Check
const supply = this.getResource('supply')
const newDemand = this.getResource('demand')
const storageMW = this.getVariable('storageMW')
const flexibleMW = this.getVariable('flexibleMW')
// Spitzenlast ist höher als Durchschnitt (Faktor ~1.35)
const peakDemand = newDemand * 1.35
// Gesicherte Leistung = flexible Kraftwerke + Speicher
const firmCapacity = flexibleMW + storageMW
if (supply < newDemand * 0.9) {
// Unterversorgung im Jahresmittel
this.changeResource('blackouts', 1)
this.addEvent('blackout', `🕯 Blackout! Stromangebot (${Math.round(supply)} MW) deckt die Nachfrage nicht.`, 'danger')
} else if (firmCapacity < peakDemand * 0.7) {
// Nicht genug gesicherte Leistung für Spitzenlast
if (Math.random() < 0.35) {
this.changeResource('blackouts', 1)
this.addEvent('peak-blackout', `⚠️ Spitzenlast-Blackout: zu wenig steuerbare Leistung im Netz.`, 'warning')
}
}
// 5. Einnahmen aus Stromverkauf (nur was wirklich verkauft wird)
const soldMW = Math.min(supply, newDemand)
const revenue = Math.round(soldMW * 0.08) // ~0.08 Mio €/MW/Jahr
this.changeResource('budget', revenue)
// 6. Wartungskosten
const upkeep = this.getVariable('upkeepTotal')
this.changeResource('budget', -upkeep)
// 7. CO₂-Strafe ab 2030 (EU-ETS-Preise steigen)
if (this.tick >= 5) {
const co2 = this.getResource('co2')
const penalty = Math.round(co2 * 0.03 * Math.min(3, (this.tick - 4) / 5))
this.changeResource('budget', -penalty)
if (penalty > 0 && this.tick === 5 && !this.firedEvent('ets-start')) {
this.addEvent('ets', `📜 EU-CO₂-Bepreisung greift: Emissionen kosten jetzt Geld.`, 'warning')
}
}
// 8. Events
if (this.weatherWindFactor < 0.85 && this.weatherSolarFactor < 0.95 && this.tick > 2) {
this.addEvent('dunkelflaute', `🌫 Dunkelflaute: wenig Wind, wenig Sonne. Versorgung knapp.`, 'warning')
}
if (this.weatherHydroFactor < 0.8) {
this.addEvent('drought', `☀️ Trockenes Jahr: Wasserkraft liefert weniger.`, 'info')
}
if (this.tick === 3 && !this.firedEvent('ev-boom')) {
this.addEvent('ev-boom', `🔋 E-Mobilitäts-Boom: Strombedarf steigt schneller als erwartet.`, 'info')
}
if (this.tick === 10 && !this.firedEvent('heat-pumps')) {
this.addEvent('heat-pumps', `🔥 Wärmepumpen-Förderung: Heizen wird elektrisch.`, 'info')
}
if (this.tick === 15 && !this.firedEvent('coal-exit')) {
const coalCount = this.getPlantCount('coal')
if (coalCount > 0) {
this.addEvent('coal-exit', `📢 Kohleausstieg beschlossen — alte Kohlekraftwerke werden unrentabel.`, 'warning')
}
}
if (this.getResource('renewable') >= 50 && !this.firedEvent('milestone-50')) {
this.addEvent('milestone-50', `🌱 Meilenstein: 50 % erneuerbare Energie erreicht!`, 'success')
}
if (this.getResource('renewable') >= 80 && !this.firedEvent('milestone-80')) {
this.addEvent('milestone-80', `🎉 80 % erneuerbar — Klimaziel erreicht!`, 'success')
}
}
private firedEvent(id: string): boolean {
if (this.firedEvents.has(id)) return true
this.firedEvents.add(id)
return false
}
protected checkLossCondition(): boolean {
if (this.getResource('budget') < -200) return true
if (this.getResource('blackouts') > 8) return true
return false
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* SIM-09: Energiemix-Simulator — LOGIK
*
* Modell:
* - Schüler*innen stellen einen Energiemix zusammen
* - Drei Zielgrößen: CO₂-Ausstoß, Kosten, Versorgungssicherheit
* - Jede Energiequelle hat spezifische Werte für alle drei Dimensionen
* - Zielkonflikt sichtbar machen: billiger = dreckiger, sauber = teurer/unsicherer
*
* Didaktik:
* - Keine "richtige" Antwort — es geht um das Abwägen
* - Urteilskompetenz: verschiedene Perspektiven einnehmen
* - Gamification: Highscore für besten Kompromiss
*/
import { Simulation, SimulationMeta } from '@core/simulation'
const META: SimulationMeta = {
id: 'sim-09',
name: 'Energiemix-Simulator',
educationLevels: [5, 6, 7, 8, 9, 10],
primaryLevel: 6,
kompetenzbereich: 'Nachhaltiger Umgang mit Energie und Ressourcen',
lernziele: [
'Erneuerbare und nicht erneuerbare Energieträger vergleichen',
'Zielkonflikte zwischen Kosten, Umwelt und Versorgungssicherheit erkennen',
'Eigene Position zu Energiepolitik bilden und begründen',
],
basiskonzepte: ['Leistungserstellung und Nachhaltigkeit', 'Ökonomische Prinzipien und Entscheidungsfindung'],
dpiMinuten: 25,
typ: 'sachsimulation',
tier: 1,
requiresReading: true,
}
export interface EnergySource {
id: string
name: string
emoji: string
color: string
co2PerGWh: number // Tonnen CO₂ pro GWh (Lifecycle)
costPerMWh: number // EUR pro MWh
reliability: number // 0-1 (1 = immer verfügbar)
maxShare: number // maximaler realistischer Anteil (0-1)
renewable: boolean
description: string
}
export const ENERGY_SOURCES: EnergySource[] = [
{
id: 'coal', name: 'Kohle', emoji: '🪨', color: '#4a4a4a',
co2PerGWh: 820, costPerMWh: 65, reliability: 0.85, maxShare: 1,
renewable: false, description: 'Billig aber sehr CO₂-intensiv'
},
{
id: 'gas', name: 'Erdgas', emoji: '🔥', color: '#c4a35a',
co2PerGWh: 490, costPerMWh: 55, reliability: 0.87, maxShare: 0.8,
renewable: false, description: 'Hälfte des CO₂ von Kohle, flexibel'
},
{
id: 'nuclear', name: 'Atomkraft', emoji: '⚛️', color: '#8a5caa',
co2PerGWh: 12, costPerMWh: 90, reliability: 0.92, maxShare: 0.6,
renewable: false, description: 'Kaum CO₂, aber teuer und Atommüll'
},
{
id: 'wind', name: 'Windkraft', emoji: '🌬️', color: '#5a9aaa',
co2PerGWh: 11, costPerMWh: 45, reliability: 0.35, maxShare: 0.5,
renewable: true, description: 'Günstig und sauber, aber wetterabhängig'
},
{
id: 'solar', name: 'Solarenergie', emoji: '☀️', color: '#e8c84a',
co2PerGWh: 45, costPerMWh: 40, reliability: 0.25, maxShare: 0.4,
renewable: true, description: 'Billigste Quelle, aber nur tagsüber'
},
{
id: 'hydro', name: 'Wasserkraft', emoji: '💧', color: '#4a7c8a',
co2PerGWh: 24, costPerMWh: 50, reliability: 0.55, maxShare: 0.3,
renewable: true, description: 'Zuverlässig, aber Standort-begrenzt'
},
]
export interface MixResult {
totalCO2: number // Tonnen CO₂ pro GWh (gewichteter Durchschnitt)
totalCost: number // EUR pro MWh
totalReliability: number // 0-1
renewableShare: number // 0-100%
score: number // Gesamtbewertung 0-100
rating: string // z.B. "Gut balanciert"
}
/**
* Berechnet die Ergebnisse eines Energiemixes
* @param mix Map von SourceID → Anteil (0-100, Summe sollte 100 sein)
*/
export function computeMixResult(mix: Record<string, number>): MixResult {
let totalCO2 = 0
let totalCost = 0
let totalReliability = 0
let renewableShare = 0
let totalShare = 0
for (const source of ENERGY_SOURCES) {
const share = (mix[source.id] || 0) / 100
totalShare += share
totalCO2 += source.co2PerGWh * share
totalCost += source.costPerMWh * share
totalReliability += source.reliability * share
if (source.renewable) renewableShare += share * 100
}
// Normalisieren falls Summe ≠ 100
if (totalShare > 0 && Math.abs(totalShare - 1) > 0.01) {
totalCO2 /= totalShare
totalCost /= totalShare
totalReliability /= totalShare
renewableShare /= totalShare
}
// Score: Multi-Kriterien-Bewertung
const co2Score = Math.max(0, 100 - totalCO2 / 8) // 0 CO₂ = 100, 800 = 0
const costScore = Math.max(0, 100 - (totalCost - 30) / 0.7) // 30€ = 100, 100€ = 0
const reliScore = totalReliability * 100
const score = Math.round((co2Score * 0.4 + costScore * 0.3 + reliScore * 0.3))
let rating = 'Experimentell'
if (score >= 80) rating = 'Exzellent! 🌟'
else if (score >= 65) rating = 'Gut balanciert 👍'
else if (score >= 50) rating = 'Solide Basis'
else if (score >= 35) rating = 'Verbesserungswürdig'
return {
totalCO2: Math.round(totalCO2),
totalCost: Math.round(totalCost),
totalReliability: Math.round(totalReliability * 100) / 100,
renewableShare: Math.round(renewableShare),
score,
rating,
}
}
export class EnergiemixSimulation extends Simulation {
constructor() {
super(META)
// Startwerte: aktueller europäischer Mix (circa)
this.state.variables = {
coal: 15, gas: 20, nuclear: 20,
wind: 18, solar: 12, hydro: 15,
}
}
getVariableRanges() {
const ranges: Record<string, { min: number; max: number; default: number; unit: string; label: string }> = {}
for (const source of ENERGY_SOURCES) {
ranges[source.id] = {
min: 0,
max: Math.round(source.maxShare * 100),
default: this.state.variables[source.id] || 0,
unit: '%',
label: `${source.emoji} ${source.name}`,
}
}
return ranges
}
/** Normalisiert den Mix auf 100% */
normalizeMix(): void {
const total = ENERGY_SOURCES.reduce((s, src) => s + this.getVariable(src.id), 0)
if (total > 0) {
for (const src of ENERGY_SOURCES) {
this.state.variables[src.id] = Math.round(this.getVariable(src.id) / total * 100)
}
}
}
compute() {
const mix: Record<string, number> = {}
for (const src of ENERGY_SOURCES) {
mix[src.id] = this.getVariable(src.id)
}
const result = computeMixResult(mix)
this.state.results = result
return result as unknown as Record<string, number>
}
protected onVariableChange(): void {
this.compute()
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* SIM-09: Energiemix-Simulator — Canvas Renderer
*
* Visualisiert:
* - Donut-Diagramm des aktuellen Mixes
* - Drei Tachos: CO₂, Kosten, Versorgungssicherheit
* - Score-Anzeige in der Mitte
* - Animierte Energie-Icons (Wind dreht, Sonne strahlt)
*
* Stil: Skandinavisch mit subtiler Bewegung
*/
import { EnergiemixSimulation, ENERGY_SOURCES, computeMixResult } from './logic'
export class EnergiemixRenderer {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private sim: EnergiemixSimulation
private W = 0
private H = 0
private t = 0
private animId = 0
private col = {
bg: '#fafaf8',
text: '#1a1a1a',
muted: '#6a6a6a',
line: '#e0ddd6',
}
constructor(container: HTMLElement, sim: EnergiemixSimulation) {
this.sim = sim
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;background:#fafaf8;'
container.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas not supported')
this.ctx = ctx
this.resize()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.W = rect.width
this.H = Math.min(rect.width * 0.7, 540)
this.canvas.width = this.W * dpr
this.canvas.height = this.H * dpr
this.canvas.style.height = this.H + 'px'
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
start(): void {
const loop = () => {
this.t += 0.016
this.draw()
this.animId = requestAnimationFrame(loop)
}
loop()
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private draw(): void {
const { ctx, W, H } = this
ctx.clearRect(0, 0, W, H)
const result = this.sim.compute()
const mix: Record<string, number> = {}
for (const src of ENERGY_SOURCES) mix[src.id] = this.sim.getVariable(src.id)
// ===== LEFT: Donut Chart =====
const donutCx = W * 0.28
const donutCy = H * 0.45
const donutR = Math.min(W * 0.18, 95)
const innerR = donutR * 0.6
let startAngle = -Math.PI / 2
const total = Object.values(mix).reduce((a, b) => a + b, 0) || 1
for (const src of ENERGY_SOURCES) {
const share = (mix[src.id] || 0) / total
if (share <= 0) continue
const angle = share * Math.PI * 2
ctx.fillStyle = src.color
ctx.beginPath()
ctx.moveTo(donutCx, donutCy)
ctx.arc(donutCx, donutCy, donutR, startAngle, startAngle + angle)
ctx.closePath()
ctx.fill()
startAngle += angle
}
// Inner hole (donut)
ctx.fillStyle = this.col.bg
ctx.beginPath()
ctx.arc(donutCx, donutCy, innerR, 0, Math.PI * 2)
ctx.fill()
// Score in center
ctx.fillStyle = this.col.text
ctx.font = 'bold 28px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(`${result.score}`, donutCx, donutCy - 4)
ctx.font = '10px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.fillText('Score', donutCx, donutCy + 14)
// Donut title
ctx.font = 'bold 13px Inter, sans-serif'
ctx.fillStyle = this.col.text
ctx.fillText('Energiemix', donutCx, donutCy - donutR - 18)
// Rating below donut
ctx.font = 'bold 11px Inter, sans-serif'
ctx.fillStyle = result.score >= 65 ? '#5a8a5e' : result.score >= 50 ? '#c4a35a' : '#c0503c'
ctx.fillText(result.rating, donutCx, donutCy + donutR + 18)
// ===== RIGHT: Three Gauges =====
const gx = W * 0.62
const gw = W * 0.32
const gaugeH = H * 0.22
// CO₂ gauge
this.drawGauge(ctx, gx, H * 0.12, gw, gaugeH,
'CO₂-Ausstoß', `${result.totalCO2} t/GWh`,
result.totalCO2, 0, 800, 'reverse', '#c0503c'
)
// Cost gauge
this.drawGauge(ctx, gx, H * 0.4, gw, gaugeH,
'Kosten', `${result.totalCost} €/MWh`,
result.totalCost, 30, 100, 'reverse', '#c4a35a'
)
// Reliability gauge
this.drawGauge(ctx, gx, H * 0.68, gw, gaugeH,
'Versorgungssicherheit', `${(result.totalReliability * 100).toFixed(0)}%`,
result.totalReliability * 100, 0, 100, 'normal', '#5a8a5e'
)
// Renewable share at bottom
ctx.font = 'bold 11px Inter, sans-serif'
ctx.fillStyle = this.col.text
ctx.textAlign = 'center'
ctx.fillText(`Erneuerbare: ${result.renewableShare}%`, donutCx, H - 18)
// Animated icons around the donut
this.drawAnimatedIcons(ctx, donutCx, donutCy, donutR + 30, mix)
}
private drawGauge(
ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number,
label: string, valueText: string,
value: number, min: number, max: number,
direction: 'normal' | 'reverse',
color: string
): void {
// Label
ctx.fillStyle = this.col.muted
ctx.font = 'bold 10px Inter, sans-serif'
ctx.textAlign = 'left'
ctx.fillText(label.toUpperCase(), x, y)
// Value
ctx.fillStyle = this.col.text
ctx.font = 'bold 18px Inter, sans-serif'
ctx.fillText(valueText, x, y + 22)
// Bar background
const barY = y + 32
const barH = 8
ctx.fillStyle = '#e8e5dc'
ctx.beginPath()
ctx.roundRect(x, barY, w, barH, 4)
ctx.fill()
// Bar fill
const ratio = Math.max(0, Math.min(1, (value - min) / (max - min)))
const fillRatio = direction === 'reverse' ? 1 - ratio : ratio
const fillW = w * Math.max(0, Math.min(1, fillRatio))
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(x, barY, fillW, barH, 4)
ctx.fill()
// Min/max labels
ctx.font = '9px Inter, sans-serif'
ctx.fillStyle = this.col.muted
ctx.textAlign = 'left'
ctx.fillText(direction === 'reverse' ? 'Schlecht' : `${min}`, x, barY + 22)
ctx.textAlign = 'right'
ctx.fillText(direction === 'reverse' ? 'Gut' : `${max}`, x + w, barY + 22)
}
private drawAnimatedIcons(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, mix: Record<string, number>): void {
let i = 0
for (const src of ENERGY_SOURCES) {
const share = mix[src.id] || 0
if (share <= 0) { i++; continue }
const angle = (i / ENERGY_SOURCES.length) * Math.PI * 2 - Math.PI / 2
const x = cx + Math.cos(angle) * r
const y = cy + Math.sin(angle) * r
// Background circle
ctx.fillStyle = src.color
ctx.globalAlpha = 0.15
ctx.beginPath()
ctx.arc(x, y, 14, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1
// Icon (emoji)
ctx.font = '14px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(src.emoji, x, y + 1)
// Share label
ctx.fillStyle = this.col.text
ctx.font = 'bold 9px Inter, sans-serif'
ctx.fillText(`${share}%`, x, y + 22)
i++
}
ctx.textBaseline = 'alphabetic'
}
}
+615
View File
@@ -0,0 +1,615 @@
/**
* SIM-10: Lieferketten-Planer*in — Was kostet dein T-Shirt?
*
* Die Anwender*in ist Einkaufsleiter*in einer Modemarke in Wien. Alle 3
* Monate (1 Tick) wird eine Charge T-Shirts (5.000 Stück) bestellt. Pro
* Bestellung wählst du:
*
* 1. Wo die BAUMWOLLE herkommt
* 2. Wo die T-Shirts GENÄHT werden
* 3. Wie sie nach WIEN transportiert werden
*
* Die Wahl beeinflusst drei Werte, die alle gleichzeitig stimmen müssen:
*
* 💰 Budget — Verkauf Einkauf Transport (12 €/Stück Marktpreis)
* 🌫 CO₂-Fußabdruck — Durchschnitt pro Stück, langfristig sichtbar
* ⚖️ Ethik-Score — Arbeitsbedingungen am Standort (0-10)
*
* Kernbotschaft: Es gibt KEINEN Königsweg. Billigster Stoff = oft schlechte
* Arbeitsbedingungen. Schnellster Weg = höchster CO₂-Ausstoß. Du musst
* abwägen — und Lehrer*innen können die Konfiguration für ihre Klasse anpassen.
*
* Konfiguration durch die Lehrperson (3 Parameter):
*
* - startMoney Startbudget (Default: 800 Mio €)
* - enabledOptions Welche Standorte/Transporte sind verfügbar?
* - enabledEvents Welche Welt-Ereignisse können auftreten?
*
* Die Defaults entsprechen dem mittleren Schwierigkeitsgrad.
*/
import { GameEngine, type GameMeta } from '@core/game-engine'
const META: GameMeta = {
id: 'sim-10',
title: 'Lieferketten-Planer*in',
description: 'Bestelle T-Shirts aus aller Welt — und finde heraus, was sie wirklich kosten.',
msPerTick: 6000, // 6 Sekunden = 3 Monate (1 Tick) — gibt Zeit zum Nachdenken
tickUnit: 'Quartal',
maxTicks: 20, // 20 Quartale = 5 Jahre
tutorialSteps: 4,
}
const START_YEAR = 2025
// === STANDORTE ===
export interface CottonSource {
id: string
name: string
country: string // Land
emoji: string
flag: string // Flag-Emoji
/** Welt-Karte: x/y in Prozent (0-100) */
pos: { x: number; y: number }
/** Preis pro T-Shirt (€) — Rohstoff-Anteil */
pricePerShirt: number
/** CO₂-Footprint Baumwoll-Anbau (kg/Stück) */
co2PerShirt: number
/** Wasser-Bedarf in L/Stück (zur Anzeige, nicht spielmechanisch) */
waterPerShirt: number
/** Arbeits-Score 0-10 (Bauern-Einkommen, Pestizid-Schutz, Kinderarbeit) */
ethicsScore: number
description: string
}
export const COTTON_SOURCES: CottonSource[] = [
{
id: 'india',
name: 'Bauer-Kollektiv Indien',
country: 'Indien',
emoji: '🌾', flag: '🇮🇳',
pos: { x: 67, y: 50 },
pricePerShirt: 2.20,
co2PerShirt: 1.8,
waterPerShirt: 2700,
ethicsScore: 4,
description: 'Größter Baumwoll-Produzent der Welt. Sehr günstig, aber viel Wasser, oft niedrige Löhne.',
},
{
id: 'usa',
name: 'Großfarm Texas',
country: 'USA',
emoji: '🌾', flag: '🇺🇸',
pos: { x: 18, y: 42 },
pricePerShirt: 3.40,
co2PerShirt: 2.6,
waterPerShirt: 1900,
ethicsScore: 7,
description: 'Industrielle Produktion. Maschinen und viel Pestizid, dafür gute Löhne und Sicherheit.',
},
{
id: 'turkey',
name: 'Genossenschaft Türkei',
country: 'Türkei',
emoji: '🌾', flag: '🇹🇷',
pos: { x: 56, y: 41 },
pricePerShirt: 3.10,
co2PerShirt: 1.5,
waterPerShirt: 2200,
ethicsScore: 6,
description: 'Mittlere Größe, näher an Europa. Solides Gleichgewicht.',
},
{
id: 'egypt',
name: 'Bio-Baumwolle Ägypten',
country: 'Ägypten',
emoji: '🌿', flag: '🇪🇬',
pos: { x: 55, y: 47 },
pricePerShirt: 4.80,
co2PerShirt: 0.9,
waterPerShirt: 2500,
ethicsScore: 9,
description: 'Hochwertige Bio-Baumwolle. Faire Löhne, weniger Pestizid — aber teuer.',
},
]
export interface Factory {
id: string
name: string
country: string
emoji: string
flag: string
pos: { x: number; y: number }
/** Verarbeitungskosten pro T-Shirt (€) */
pricePerShirt: number
/** CO₂ in der Produktion (Strom + Färben) */
co2PerShirt: number
/** Arbeits-Score 0-10 (Sicherheit, Lohn, Stunden) */
ethicsScore: number
description: string
}
export const FACTORIES: Factory[] = [
{
id: 'bangladesh',
name: 'Mega-Näherei Dhaka',
country: 'Bangladesch',
emoji: '🏭', flag: '🇧🇩',
pos: { x: 70, y: 50 },
pricePerShirt: 0.80,
co2PerShirt: 1.4,
ethicsScore: 3,
description: 'Sehr billig. Schwache Arbeitsbedingungen, lange Stunden, niedrige Löhne.',
},
{
id: 'vietnam',
name: 'Industrie-Park Vietnam',
country: 'Vietnam',
emoji: '🏭', flag: '🇻🇳',
pos: { x: 78, y: 54 },
pricePerShirt: 1.10,
co2PerShirt: 1.6,
ethicsScore: 5,
description: 'Mittlere Preise, mittlere Bedingungen. Solider Standard.',
},
{
id: 'turkey-fab',
name: 'Familien-Manufaktur Türkei',
country: 'Türkei',
emoji: '🏭', flag: '🇹🇷',
pos: { x: 56, y: 41 },
pricePerShirt: 1.80,
co2PerShirt: 1.0,
ethicsScore: 7,
description: 'Kleinere Betriebe, kürzere Wege nach Europa, bessere Bedingungen.',
},
{
id: 'portugal',
name: 'Öko-Manufaktur Portugal',
country: 'Portugal',
emoji: '🏭', flag: '🇵🇹',
pos: { x: 42, y: 39 },
pricePerShirt: 3.20,
co2PerShirt: 0.5,
ethicsScore: 9,
description: 'EU-Standards: Mindestlohn, Sicherheit, grüner Strom. Aber deutlich teurer.',
},
]
// === ZIEL: WIEN ===
export const VIENNA = { x: 49, y: 38 }
// === TRANSPORT ===
export interface TransportMode {
id: 'ship' | 'truck' | 'plane'
name: string
emoji: string
/** Kosten pro 1000 km pro Stück (€) */
costPer1000km: number
/** CO₂ pro 1000 km pro Stück (kg) */
co2Per1000km: number
/** Lieferzeit in Tagen pro 1000 km */
daysPer1000km: number
description: string
}
export const TRANSPORT_MODES: TransportMode[] = [
{
id: 'ship',
name: 'Containerschiff',
emoji: '🚢',
costPer1000km: 0.06,
co2Per1000km: 0.10,
daysPer1000km: 4.0,
description: 'Sehr günstig und sehr klimafreundlich pro Stück. Aber langsam — und nicht überall hin.',
},
{
id: 'truck',
name: 'LKW',
emoji: '🚛',
costPer1000km: 0.18,
co2Per1000km: 0.65,
daysPer1000km: 1.5,
description: 'Schneller als Schiff. Mittlere Kosten, hohe CO₂-Last.',
},
{
id: 'plane',
name: 'Frachtflugzeug',
emoji: '✈️',
costPer1000km: 1.20,
co2Per1000km: 2.40,
daysPer1000km: 0.3,
description: 'Über Nacht da. Teuer und mit Abstand der größte CO₂-Ausstoß.',
},
]
// Vereinfachte Distanztabelle (km, Luftlinie / Schiff je nach Ausrichtung)
// Wir verwenden für ALLE Transportmodi denselben Wert — vereinfacht didaktisch.
const DIST_KM: Record<string, Record<string, number>> = {
india: { bangladesh: 1700, vietnam: 2700, 'turkey-fab': 4400, portugal: 8000 },
usa: { bangladesh: 13000, vietnam: 13500, 'turkey-fab': 9500, portugal: 7800 },
turkey: { bangladesh: 4500, vietnam: 7800, 'turkey-fab': 0, portugal: 3200 },
egypt: { bangladesh: 5800, vietnam: 8200, 'turkey-fab': 1100, portugal: 3700 },
}
const DIST_TO_VIENNA: Record<string, number> = {
bangladesh: 7000,
vietnam: 9000,
'turkey-fab': 1600,
portugal: 2200,
}
/** Eine konkret bestellte Charge */
export interface Order {
cottonId: string
factoryId: string
transport1: TransportMode['id'] // Baumwolle → Näherei
transport2: TransportMode['id'] // Näherei → Wien
}
// === KONFIGURATION ===
/**
* Lehrer-Konfiguration. Drei Parameter, die ein*e Lehrperson später
* über ein Dashboard setzen kann (URL-Parameter heute, UI später).
*
* Defaults entsprechen dem mittleren Schwierigkeitsgrad.
*/
export interface SimConfig {
/** Startbudget in Mio € */
startMoney: number
/** IDs der zugelassenen Cotton-Sources / Factories / Transport-Modi */
enabledOptions: {
cotton: string[]
factory: string[]
transport: TransportMode['id'][]
}
/** IDs der zugelassenen Welt-Ereignisse */
enabledEvents: string[]
}
export const DEFAULT_CONFIG: SimConfig = {
startMoney: 800,
enabledOptions: {
cotton: ['india', 'usa', 'turkey', 'egypt'],
factory: ['bangladesh', 'vietnam', 'turkey-fab', 'portugal'],
transport: ['ship', 'truck', 'plane'],
},
enabledEvents: ['drought-india', 'wage-bangladesh', 'suez', 'eu-co2-tax', 'consumer-pressure'],
}
/** Spiel-Mechanik */
const SHIRTS_PER_ORDER = 5000
const MARKET_PRICE = 12.0 // €/Stück, was du für ein T-Shirt bekommst
const CO2_GOAL_KG = 6.0 // unter 6 kg CO₂/Stück = klimaziel
const ETHICS_GOAL = 5.0 // ≥ 5 = ethik-ziel
interface OrderHistory {
tick: number
cottonId: string
factoryId: string
transport1: TransportMode['id']
transport2: TransportMode['id']
shirts: number
costPerShirt: number
co2PerShirt: number
ethicsScore: number
profit: number
}
export class LieferkettenGame extends GameEngine {
private config: SimConfig
private history: OrderHistory[] = []
/** Zeitlich verschobene Welt-Ereignisse — können Werte mutieren */
private activeEffects: Record<string, number> = {}
/** Bisher gefeuerte Events */
private firedEvents = new Set<string>()
constructor(config: Partial<SimConfig> = {}) {
super(META)
this.config = {
...DEFAULT_CONFIG,
...config,
enabledOptions: { ...DEFAULT_CONFIG.enabledOptions, ...(config.enabledOptions || {}) },
}
this.setupResources()
this.setupGoals()
this.setupTutorial()
}
getConfig(): SimConfig {
return this.config
}
getHistory(): OrderHistory[] {
return this.history
}
getCurrentYear(): number {
return START_YEAR + Math.floor(this.tick / 4)
}
private setupResources(): void {
this.addResource({
id: 'budget',
name: 'Budget',
icon: '💰',
initial: this.config.startMoney,
unit: 'Mio €',
format: (v) => `${Math.round(v)} Mio €`,
})
this.addResource({
id: 'co2_avg',
name: 'Ø CO₂ pro Shirt',
icon: '🌫',
initial: 0,
unit: 'kg',
format: (v) => `${v.toFixed(1)} kg`,
})
this.addResource({
id: 'ethics_avg',
name: 'Ø Ethik-Score',
icon: '⚖️',
initial: 0,
unit: 'von 10',
format: (v) => `${v.toFixed(1)} / 10`,
})
this.addResource({
id: 'shirts_total',
name: 'Verkaufte Shirts',
icon: '👕',
initial: 0,
unit: 'Stück',
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
})
}
private setupGoals(): void {
this.addGoal({
id: 'survive',
title: 'Bis Quartal 20 überleben',
description: 'Halte dein Budget über 0 Mio € durchgehend.',
check: (g) => (g as LieferkettenGame).tick >= 20,
progress: (g) => Math.min(100, ((g as LieferkettenGame).tick / 20) * 100),
required: true,
})
this.addGoal({
id: 'co2',
title: `Ø CO₂ unter ${CO2_GOAL_KG} kg/Shirt`,
description: 'Nachhaltige Lieferketten — der Klima-Fußabdruck deiner Modemarke.',
check: (g) => {
const v = g.getResource('co2_avg')
return v > 0 && v < CO2_GOAL_KG
},
progress: (g) => {
const v = g.getResource('co2_avg')
if (v === 0) return 0
return Math.max(0, Math.min(100, (1 - (v - 4) / 6) * 100))
},
required: true,
})
this.addGoal({
id: 'ethics',
title: `Ø Ethik-Score über ${ETHICS_GOAL}`,
description: 'Faire Arbeitsbedingungen entlang der ganzen Lieferkette.',
check: (g) => g.getResource('ethics_avg') >= ETHICS_GOAL,
progress: (g) => Math.max(0, Math.min(100, (g.getResource('ethics_avg') / 10) * 100)),
required: true,
})
this.addGoal({
id: 'budget',
title: 'Nicht pleite gehen',
description: 'Halte ein positives Budget bis zum Spielende.',
check: (g) => g.getResource('budget') > 0,
required: true,
})
}
private setupTutorial(): void {
this.setTutorial([
{
triggerTick: 0,
title: 'Willkommen, Einkaufsleiter*in!',
text: 'Du leitest den Einkauf einer kleinen Modemarke in Wien. Alle 3 Monate (=1 Quartal) musst du eine neue Charge T-Shirts bestellen.\n\nEine Charge sind 5.000 Stück. Du verkaufst sie für 12 € pro Stück — das macht 60.000 € Umsatz pro Charge.\n\nAber: Dein Gewinn hängt davon ab, was du für die Produktion bezahlst.',
unlocks: ['budget-display'],
},
{
triggerTick: 0,
title: 'Drei Stufen der Lieferkette',
text: 'Jede Bestellung hat 3 Entscheidungen:\n\n🌾 BAUMWOLLE — Wo wird sie angebaut? (4 Länder)\n🏭 NÄHEREI — Wo wird das Shirt genäht? (4 Länder)\n🚢 TRANSPORT — Wie kommt es nach Wien? (Schiff/LKW/Flugzeug)\n\nEs gibt also 4 × 4 × 3 × 3 = 144 mögliche Lieferketten. Welche ist die beste? Es kommt darauf an, was DU willst.',
unlocks: ['order-form'],
},
{
triggerTick: 0,
title: 'Drei Werte zählen',
text: 'Du hast 3 Hauptziele — alle gleichzeitig:\n\n💰 BUDGET — bleibe profitabel\n🌫 CO₂ — der durchschnittliche Klimaabdruck pro Shirt soll unter 6 kg bleiben\n⚖️ ETHIK — der Ø-Wert für Arbeitsbedingungen soll über 5 von 10 liegen\n\nDie drei stehen oft im Widerspruch. Du musst abwägen — wie im echten Leben.',
unlocks: ['goals'],
},
{
triggerTick: 0,
title: 'Bereit?',
text: 'Im Verlauf der Simulation passieren Welt-Ereignisse: Dürren, Lohnerhöhungen, Suezkanal-Stau, Klimazölle. Sie verändern die Werte einzelner Standorte.\n\nDu hast 20 Quartale (= 5 Jahre) Zeit. Klick eine Bestellung zusammen und drücke „Bestellen".\n\nLos geht\'s! 🚢',
unlocks: ['controls'],
},
])
}
/**
* Simuliere eine Bestellung (vom Spieler ausgelöst). Wendet sofort die
* Effekte an und schreibt einen History-Eintrag.
*/
placeOrder(order: Order): boolean {
const cotton = COTTON_SOURCES.find(c => c.id === order.cottonId)
const factory = FACTORIES.find(f => f.id === order.factoryId)
const t1 = TRANSPORT_MODES.find(t => t.id === order.transport1)
const t2 = TRANSPORT_MODES.find(t => t.id === order.transport2)
if (!cotton || !factory || !t1 || !t2) return false
// Welt-Effekte anwenden
const cottonPrice = cotton.pricePerShirt * (1 + (this.activeEffects[`cotton-${cotton.id}-price`] || 0))
const factoryPrice = factory.pricePerShirt * (1 + (this.activeEffects[`factory-${factory.id}-price`] || 0))
const cottonCO2 = cotton.co2PerShirt
const factoryCO2 = factory.co2PerShirt
const cottonEthics = cotton.ethicsScore + (this.activeEffects[`cotton-${cotton.id}-ethics`] || 0)
const factoryEthics = factory.ethicsScore + (this.activeEffects[`factory-${factory.id}-ethics`] || 0)
// Distanzen
const dist1 = (DIST_KM[cotton.id]?.[factory.id] ?? 5000) / 1000
const dist2 = (DIST_TO_VIENNA[factory.id] ?? 3000) / 1000
// Transportkosten und CO₂
const t1Cost = t1.costPer1000km * dist1 * (1 + (this.activeEffects[`transport-${t1.id}-cost`] || 0))
const t2Cost = t2.costPer1000km * dist2 * (1 + (this.activeEffects[`transport-${t2.id}-cost`] || 0))
const t1CO2 = t1.co2Per1000km * dist1
const t2CO2 = t2.co2Per1000km * dist2
// Pro-Stück-Werte
const costPerShirt = cottonPrice + factoryPrice + t1Cost + t2Cost
const co2PerShirt = cottonCO2 + factoryCO2 + t1CO2 + t2CO2
// Gewichteter Ethik-Mittelwert (Cotton 50 %, Factory 50 %)
const ethicsScore = (cottonEthics + factoryEthics) / 2
// Wirtschaft: Verkauf Einkauf Transport (bezogen auf 5000 Stück)
// Wir rechnen in Tausend €, dann zu Mio € → /1000
const totalRevenue = SHIRTS_PER_ORDER * MARKET_PRICE / 1000 // k€
const totalCost = SHIRTS_PER_ORDER * costPerShirt / 1000 // k€
const profitKEur = totalRevenue - totalCost
const profitMio = profitKEur / 1000 // Mio €
this.changeResource('budget', profitMio)
// Durchschnittliche CO₂- und Ethik-Werte aktualisieren (gewichteter Mittelwert)
const oldShirts = this.getResource('shirts_total')
const newShirts = oldShirts + SHIRTS_PER_ORDER
const oldCO2avg = this.getResource('co2_avg')
const newCO2avg = oldShirts === 0
? co2PerShirt
: (oldCO2avg * oldShirts + co2PerShirt * SHIRTS_PER_ORDER) / newShirts
const oldEthAvg = this.getResource('ethics_avg')
const newEthAvg = oldShirts === 0
? ethicsScore
: (oldEthAvg * oldShirts + ethicsScore * SHIRTS_PER_ORDER) / newShirts
this.setResource('co2_avg', newCO2avg)
this.setResource('ethics_avg', newEthAvg)
this.setResource('shirts_total', newShirts)
// History
this.history.push({
tick: this.tick,
cottonId: cotton.id,
factoryId: factory.id,
transport1: t1.id,
transport2: t2.id,
shirts: SHIRTS_PER_ORDER,
costPerShirt,
co2PerShirt,
ethicsScore,
profit: profitMio,
})
// Event für die Anzeige
const profitStr = profitMio >= 0 ? `+${profitMio.toFixed(1)}` : profitMio.toFixed(1)
this.addEvent(
'order-' + this.tick + '-' + this.history.length,
`${cotton.flag}${factory.flag} ${t1.emoji}${t2.emoji} · ${profitStr} Mio € · ${co2PerShirt.toFixed(1)} kg CO₂ · Ethik ${ethicsScore.toFixed(0)}/10`,
profitMio >= 0 ? 'success' : 'warning',
)
this.notify()
return true
}
protected simulateTick(): void {
// Fixkosten pro Quartal (Marketing, Mieten, Lohn): 8 Mio €
this.changeResource('budget', -8)
// Welt-Ereignisse (Tick-basiert, einmalig)
this.maybeFireEvent()
}
/** Welt-Ereignisse, die Standort-Werte verändern */
private maybeFireEvent(): void {
const enabled = new Set(this.config.enabledEvents)
const fire = (id: string, body: () => void) => {
if (!enabled.has(id) || this.firedEvents.has(id)) return
this.firedEvents.add(id)
body()
}
if (this.tick === 5) {
fire('drought-india', () => {
this.activeEffects['cotton-india-price'] = 0.40
this.addEvent(
'drought-india',
'🌵 Trockenheit in Indien — Baumwoll-Preis +40 %.',
'warning',
)
})
}
if (this.tick === 8) {
fire('wage-bangladesh', () => {
this.activeEffects['factory-bangladesh-price'] = 0.30
this.activeEffects['factory-bangladesh-ethics'] = 2
this.addEvent(
'wage-bangladesh',
'✊ Bangladesch erhöht den Mindestlohn — +30 % Preis, aber +2 Ethik.',
'info',
)
})
}
if (this.tick === 11) {
fire('suez', () => {
this.activeEffects['transport-ship-cost'] = 0.50
this.addEvent(
'suez',
'🚧 Schiffs-Stau im Suezkanal — Containerschiff +50 % Kosten.',
'warning',
)
})
}
if (this.tick === 14) {
fire('eu-co2-tax', () => {
this.activeEffects['transport-plane-cost'] = 0.60
this.activeEffects['transport-truck-cost'] = 0.30
this.addEvent(
'eu-co2-tax',
'🇪🇺 EU-Klimazoll — Flug +60 %, LKW +30 %.',
'warning',
)
})
}
if (this.tick === 17) {
fire('consumer-pressure', () => {
// Konsumenten verlangen Nachhaltigkeit — Bonus für Bio/EU
this.activeEffects['cotton-egypt-price'] = -0.15
this.activeEffects['factory-portugal-price'] = -0.15
this.addEvent(
'consumer-pressure',
'📰 Kund*innen verlangen Nachhaltigkeit — Bio-Baumwolle und EU-Manufaktur 15 %.',
'success',
)
})
}
}
protected checkLossCondition(): boolean {
return this.getResource('budget') < -100
}
protected serializeSubclass(): Record<string, unknown> {
return {
config: this.config,
history: this.history,
activeEffects: this.activeEffects,
firedEvents: Array.from(this.firedEvents),
}
}
protected deserializeSubclass(data: Record<string, unknown>): void {
if (data.config) this.config = data.config as SimConfig
if (Array.isArray(data.history)) this.history = data.history as OrderHistory[]
if (data.activeEffects) this.activeEffects = data.activeEffects as Record<string, number>
if (Array.isArray(data.firedEvents)) this.firedEvents = new Set(data.firedEvents as string[])
}
}
@@ -0,0 +1,269 @@
/**
* SIM-10: Lieferketten-Renderer (SVG, 2D)
*
* Zeichnet eine vereinfachte Weltkarte als SVG, mit Markern für die
* Baumwoll-Quellen, Nähereien und Wien. Wenn die Anwender*in eine Route
* im Bestell-Formular zusammenstellt, wird sie hier als gestrichelte Linie
* angezeigt — und beim Bestellen ein kleines Transport-Sprite (🚢/🚛/✈️)
* über die Linie animiert.
*
* Das ist BEWUSST viel einfacher als der Klimawächter-3D-Renderer:
* - Keine Three.js, kein WebGL — nur SVG
* - Keine 60fps-Loop, nur Update bei Bedarf
* - Komplett pixel-flach, keine Schatten, kein Performance-Risiko
*/
import {
COTTON_SOURCES,
FACTORIES,
TRANSPORT_MODES,
VIENNA,
type LieferkettenGame,
type Order,
} from './game'
const SVG_NS = 'http://www.w3.org/2000/svg'
export class LieferkettenRenderer {
private container: HTMLElement
private game: LieferkettenGame
private svg!: SVGSVGElement
/** Aktuell im Bestell-Formular gewählte Route (für die Vorschau-Linie) */
private previewOrder: Partial<Order> = {}
private animationStart = 0
private animatedSprites: Array<{ el: SVGTextElement; from: { x: number; y: number }; to: { x: number; y: number }; start: number; duration: number }> = []
private rafId = 0
constructor(container: HTMLElement, game: LieferkettenGame) {
this.container = container
this.game = game
}
start(): void {
this.buildSVG()
this.draw()
this.tick()
}
stop(): void {
if (this.rafId) cancelAnimationFrame(this.rafId)
this.rafId = 0
}
/** Wird vom HTML aufgerufen, wenn der Spieler eine Bestell-Auswahl ändert */
setPreviewOrder(order: Partial<Order>): void {
this.previewOrder = { ...order }
this.draw()
}
/** Wird beim Bestellen aufgerufen — animiert ein Sprite über die Route */
animateOrder(order: Order): void {
const cotton = COTTON_SOURCES.find(c => c.id === order.cottonId)
const factory = FACTORIES.find(f => f.id === order.factoryId)
const t1 = TRANSPORT_MODES.find(t => t.id === order.transport1)
const t2 = TRANSPORT_MODES.find(t => t.id === order.transport2)
if (!cotton || !factory || !t1 || !t2) return
// Sprite 1: Baumwolle → Näherei
const s1 = this.makeSprite(t1.emoji)
this.animatedSprites.push({
el: s1,
from: cotton.pos,
to: factory.pos,
start: performance.now(),
duration: 1500,
})
// Sprite 2: Näherei → Wien (startet etwas später)
const s2 = this.makeSprite(t2.emoji)
this.animatedSprites.push({
el: s2,
from: factory.pos,
to: VIENNA,
start: performance.now() + 800,
duration: 1500,
})
}
// ============================================================
// Interna
// ============================================================
private buildSVG(): void {
this.container.innerHTML = ''
this.container.style.cssText = 'position:fixed;inset:0;background:linear-gradient(180deg,#dceaf0 0%,#bcd5e6 100%);'
this.svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
this.svg.setAttribute('viewBox', '0 0 100 60')
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
this.svg.style.cssText = 'width:100%;height:100%;display:block;'
this.container.appendChild(this.svg)
}
private draw(): void {
if (!this.svg) return
// Layers: Karte, Linien, Marker, Animation
this.svg.innerHTML = `
${this.drawWorldShape()}
${this.drawRouteLines()}
${this.drawMarkers()}
`
// Animations-Sprites werden separat als DOM-Knoten gehalten
for (const s of this.animatedSprites) {
this.svg.appendChild(s.el)
}
}
/**
* Sehr stark vereinfachte Welt-Silhouette als <path>.
* Wir zeichnen 4 grobe Kontinent-Blobs — keine Geo-Genauigkeit, nur damit
* die Marker im richtigen Bereich sitzen.
*/
private drawWorldShape(): string {
const oceanGrad = `
<defs>
<linearGradient id="ocean" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#dceaf0" />
<stop offset="100%" stop-color="#a0c0d0" />
</linearGradient>
</defs>
`
// Kontinent-Pfade — handgemalt, didaktische Vereinfachung
const continents = `
<!-- Nordamerika -->
<path d="M 5,25 Q 8,20 14,22 Q 20,21 24,28 Q 26,38 22,46 Q 16,50 10,46 Q 4,40 5,25 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Südamerika -->
<path d="M 20,48 Q 24,46 26,50 Q 28,55 24,58 Q 20,58 19,53 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Europa -->
<path d="M 42,30 Q 48,28 52,32 Q 53,38 50,42 Q 44,42 42,38 Q 41,33 42,30 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Afrika -->
<path d="M 46,42 Q 52,42 55,48 Q 56,55 50,57 Q 45,55 44,49 Q 44,44 46,42 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Asien (groß, diffus) -->
<path d="M 53,28 Q 65,24 78,28 Q 86,32 84,42 Q 78,48 72,50 Q 60,48 56,42 Q 54,36 53,28 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Indien-Spitze -->
<path d="M 65,46 Q 68,45 70,50 Q 67,53 64,51 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
<!-- Australien -->
<path d="M 80,52 Q 86,50 88,54 Q 86,57 81,56 Z"
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
`
return `${oceanGrad}<rect width="100" height="60" fill="url(#ocean)" />${continents}`
}
/** Zeichnet Routen-Linien:
* - dünne graue Linien für ALLE möglichen Verbindungen (zur Orientierung)
* - dicke gestrichelte Linie für die aktuell im Formular gewählte Route */
private drawRouteLines(): string {
let lines = ''
// Vorschau-Route (wenn etwas gewählt ist)
if (this.previewOrder.cottonId && this.previewOrder.factoryId) {
const c = COTTON_SOURCES.find(s => s.id === this.previewOrder.cottonId)
const f = FACTORIES.find(x => x.id === this.previewOrder.factoryId)
if (c && f) {
lines += this.line(c.pos, f.pos, '#c07a6b', 0.45)
}
}
if (this.previewOrder.factoryId) {
const f = FACTORIES.find(x => x.id === this.previewOrder.factoryId)
if (f) {
lines += this.line(f.pos, VIENNA, '#c07a6b', 0.45)
}
}
return lines
}
private line(a: { x: number; y: number }, b: { x: number; y: number }, color: string, w: number): string {
return `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"
stroke="${color}" stroke-width="${w}" stroke-dasharray="1.2 0.8"
stroke-linecap="round" />`
}
private drawMarkers(): string {
let out = ''
// Wien als rotes Ziel
out += this.markerWithLabel(VIENNA.x, VIENNA.y, '🏛', 'Wien', '#b04a3a', 2.6)
// Baumwoll-Quellen
for (const c of COTTON_SOURCES) {
const isSelected = this.previewOrder.cottonId === c.id
out += this.markerWithLabel(
c.pos.x, c.pos.y,
c.flag, c.country,
isSelected ? '#5a8a5e' : '#5a8a8a',
isSelected ? 2.4 : 2.0,
)
}
// Nähereien
for (const f of FACTORIES) {
const isSelected = this.previewOrder.factoryId === f.id
// Position leicht versetzen, falls Factory am gleichen Ort wie Cotton
const dx = (f.id === 'turkey-fab' && COTTON_SOURCES.some(c => c.id === 'turkey')) ? 1.5 : 0
const dy = (f.id === 'turkey-fab' && COTTON_SOURCES.some(c => c.id === 'turkey')) ? 1.5 : 0
out += this.markerWithLabel(
f.pos.x + dx, f.pos.y + dy,
f.emoji, f.country,
isSelected ? '#5a8a5e' : '#7a6a5a',
isSelected ? 2.4 : 2.0,
)
}
return out
}
private markerWithLabel(x: number, y: number, emoji: string, label: string, color: string, size: number): string {
return `
<g>
<circle cx="${x}" cy="${y}" r="${size}" fill="${color}" opacity="0.85" />
<circle cx="${x}" cy="${y}" r="${size + 0.4}" fill="none" stroke="#fff" stroke-width="0.3" />
<text x="${x}" y="${y + size * 0.45}" text-anchor="middle" font-size="${size * 1.4}">${emoji}</text>
<text x="${x}" y="${y + size + 1.6}" text-anchor="middle" font-size="1.4"
font-weight="700" fill="#1a1a1a"
style="paint-order: stroke; stroke: #fff; stroke-width: 0.5;">${this.escape(label)}</text>
</g>
`
}
private makeSprite(emoji: string): SVGTextElement {
const el = document.createElementNS(SVG_NS, 'text') as SVGTextElement
el.setAttribute('font-size', '2.6')
el.setAttribute('text-anchor', 'middle')
el.textContent = emoji
return el
}
private tick(): void {
const now = performance.now()
let stillRunning = false
for (let i = this.animatedSprites.length - 1; i >= 0; i--) {
const s = this.animatedSprites[i]
const t = (now - s.start) / s.duration
if (t < 0) {
// noch nicht gestartet — verstecken
s.el.setAttribute('x', '-100')
s.el.setAttribute('y', '-100')
stillRunning = true
continue
}
if (t >= 1) {
// Fertig — entfernen
if (s.el.parentNode) s.el.parentNode.removeChild(s.el)
this.animatedSprites.splice(i, 1)
continue
}
// Linear interpolieren mit kleiner Hubbel
const x = s.from.x + (s.to.x - s.from.x) * t
const y = s.from.y + (s.to.y - s.from.y) * t - Math.sin(t * Math.PI) * 1.5
s.el.setAttribute('x', String(x))
s.el.setAttribute('y', String(y))
stillRunning = true
}
// rAF-Loop
this.rafId = requestAnimationFrame(() => this.tick())
}
private escape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* SIM-11: Forscher*in im Regenwald — Daten
*
* Eine scrollende Flussreise durch drei Klima-Zonen des Regenwalds.
* An jedem Halt gibt es ein Tier/Pflanze/Phänomen zu entdecken.
*
* KEIN GameEngine-Erbe — das ist kein Tick-basiertes Spiel, sondern
* ein Explorations-Tool mit Sammelheft.
*/
export interface Discoverable {
id: string
emoji: string
name: string
/** Position auf der Karte: 0 = Start (Flussmündung), 100 = Ende (Bergregenwald) */
position: number
/** In welcher Zone liegt das Tier? */
zone: 'delta' | 'tiefland' | 'berg'
/** 2-3 kindgerechte Sätze */
fact: string
/** Optionaler Funfact / Staunen-Satz */
wow?: string
/** Ist es ein Tier, eine Pflanze, ein Mensch oder ein Phänomen? */
type: 'tier' | 'pflanze' | 'mensch' | 'phaenomen'
}
/**
* 15 entdeckbare Stationen entlang des Flusses.
* Verteilt über 3 Zonen: Delta (0-33), Tiefland (34-66), Berg (67-100).
*/
export const DISCOVERIES: Discoverable[] = [
// === DELTA (Flussmündung, flach, warm, feucht) ===
{
id: 'krokodil',
emoji: '🐊',
name: 'Krokodil',
position: 5,
zone: 'delta',
type: 'tier',
fact: 'Krokodile leben seit über 200 Millionen Jahren auf der Erde — sie waren schon da, als die Dinosaurier lebten! Sie lauern im flachen Wasser und sind blitzschnell.',
wow: 'Ein Krokodil kann über eine Stunde die Luft anhalten.',
},
{
id: 'papagei',
emoji: '🦜',
name: 'Ara-Papagei',
position: 12,
zone: 'delta',
type: 'tier',
fact: 'Aras sind die buntesten Vögel im Regenwald. Sie leben in Paaren und bleiben ihr ganzes Leben zusammen. Ihr lauter Ruf ist kilometerweit zu hören.',
wow: 'Aras fressen Lehm von Flussufern — das hilft gegen Gifte in unreifen Früchten!',
},
{
id: 'mangrove',
emoji: '🌿',
name: 'Mangroven-Baum',
position: 18,
zone: 'delta',
type: 'pflanze',
fact: 'Mangroven sind die einzigen Bäume, die im Salzwasser wachsen können. Ihre Wurzeln ragen wie Stelzen aus dem Wasser und schützen die Küste vor Stürmen und Wellen.',
wow: 'In den Mangrovenwurzeln leben hunderte kleine Fische, Krebse und Schnecken — ein ganzes Unterwasser-Dorf!',
},
{
id: 'flussdelfin',
emoji: '🐬',
name: 'Rosa Flussdelfin',
position: 25,
zone: 'delta',
type: 'tier',
fact: 'Im Amazonas schwimmen rosa Delfine! Sie sind tatsächlich pink — niemand weiß genau warum. Sie sind scheu und schwer zu beobachten.',
wow: 'Flussdelfine können ihren Kopf in alle Richtungen drehen — das können Meerdelfine nicht.',
},
{
id: 'fischer',
emoji: '🧑',
name: 'Fischer-Familie',
position: 30,
zone: 'delta',
type: 'mensch',
fact: 'Am Flussufer leben Familien, die vom Fischfang leben. Sie kennen den Fluss besser als jede Landkarte. Ihr Wissen wird seit Generationen weitergegeben.',
},
// === TIEFLAND (dichter Dschungel, wenig Licht am Boden) ===
{
id: 'jaguar',
emoji: '🐆',
name: 'Jaguar',
position: 38,
zone: 'tiefland',
type: 'tier',
fact: 'Der Jaguar ist die größte Raubkatze Südamerikas. Er kann klettern, schwimmen und sogar unter Wasser jagen. Am liebsten frisst er Pekaris und Kaimane.',
wow: 'Jaguare haben den stärksten Biss aller Großkatzen — sie knacken sogar Schildkrötenpanzer.',
},
{
id: 'faultier',
emoji: '🦥',
name: 'Dreifinger-Faultier',
position: 45,
zone: 'tiefland',
type: 'tier',
fact: 'Faultiere bewegen sich so langsam, dass auf ihrem Fell Algen wachsen — das macht sie grünlich und tarnt sie zwischen den Blättern. Sie schlafen bis zu 20 Stunden am Tag.',
wow: 'Ein Faultier braucht einen ganzen Monat, um ein einziges Blatt zu verdauen!',
},
{
id: 'ameisen',
emoji: '🐜',
name: 'Blattschneider-Ameisen',
position: 50,
zone: 'tiefland',
type: 'tier',
fact: 'Diese Ameisen schneiden Blattstücke ab und tragen sie in ihren Bau. Aber sie essen die Blätter nicht — sie züchten damit einen Pilz, der ihr eigentliches Essen ist! Sie sind also Pilz-Bauern.',
wow: 'Eine Blattschneider-Kolonie kann bis zu 8 Millionen Ameisen haben.',
},
{
id: 'orchidee',
emoji: '🌺',
name: 'Riesen-Orchidee',
position: 55,
zone: 'tiefland',
type: 'pflanze',
fact: 'Im Regenwald wachsen über 25.000 Orchideen-Arten. Manche blühen nur eine einzige Nacht lang. Viele wachsen hoch oben auf Baumstämmen, weil dort mehr Licht hinkommt.',
},
{
id: 'indigene',
emoji: '🧑',
name: 'Indigene Gemeinschaft',
position: 62,
zone: 'tiefland',
type: 'mensch',
fact: 'Im Regenwald leben Menschen, deren Familien dort seit Tausenden von Jahren zuhause sind. Sie kennen jede Pflanze, wissen welche heilen und welche giftig sind. Ihr Wissen ist ein Schatz.',
wow: 'Über 80 % aller Medikamente haben ihren Ursprung in Pflanzen aus dem Regenwald!',
},
// === BERGREGENWALD (kühler, neblig, moosig) ===
{
id: 'affe',
emoji: '🐒',
name: 'Brüllaffe',
position: 70,
zone: 'berg',
type: 'tier',
fact: 'Brüllaffen sind die lautesten Landtiere der Welt. Ihren Ruf hört man bis zu 5 Kilometer weit! Sie brüllen morgens, um ihr Revier zu markieren — ohne zu kämpfen.',
wow: 'Sie brüllen so laut wie ein Düsenflugzeug beim Start — 140 Dezibel!',
},
{
id: 'kolibri',
emoji: '🐦',
name: 'Kolibri',
position: 78,
zone: 'berg',
type: 'tier',
fact: 'Kolibris sind die kleinsten Vögel der Welt. Sie können in der Luft stehen bleiben und sogar rückwärts fliegen! Ihr Herz schlägt bis zu 1.200 Mal pro Minute.',
wow: 'Ein Kolibri trinkt am Tag doppelt so viel Nektar wie sein Körpergewicht.',
},
{
id: 'nebel',
emoji: '🌫',
name: 'Nebelwald',
position: 85,
zone: 'berg',
type: 'phaenomen',
fact: 'Im Bergregenwald hängt fast immer dichter Nebel zwischen den Bäumen. Die Feuchtigkeit sammelt sich an den Blättern und tropft herunter — wie ein unsichtbarer Regen, der den Wald von innen gießt.',
wow: 'Im Nebelwald wächst mehr Moos als irgendwo sonst auf der Erde.',
},
{
id: 'schlange',
emoji: '🐍',
name: 'Smaragd-Baumboa',
position: 90,
zone: 'berg',
type: 'tier',
fact: 'Die Smaragd-Baumboa ist leuchtend grün und hängt zusammengerollt auf Ästen. Sie jagt Vögel und Eidechsen — und ist völlig harmlos für Menschen, auch wenn sie gefährlich aussieht.',
},
{
id: 'rodung',
emoji: '🪓',
name: 'Gerodete Fläche',
position: 96,
zone: 'berg',
type: 'phaenomen',
fact: 'Hier standen früher riesige Bäume. Jetzt ist der Boden kahl — gerodet für Rinderweiden oder Soja-Felder. Jedes Jahr verschwindet eine Regenwald-Fläche so groß wie die Schweiz.',
wow: 'Wenn der Regenwald ganz verschwindet, verlieren wir Millionen von Tier- und Pflanzenarten — für immer. Viele davon kennen wir noch gar nicht.',
},
]
/** Die 3 Klimazonen des Flusslaufs */
export interface Zone {
id: 'delta' | 'tiefland' | 'berg'
name: string
emoji: string
/** Hintergrundfarbe (CSS-Gradient) */
bgFrom: string
bgTo: string
description: string
}
export const ZONES: Zone[] = [
{
id: 'delta',
name: 'Flussmündung',
emoji: '🏝',
bgFrom: '#a8d8c8',
bgTo: '#7ab8a0',
description: 'Flach, warm, feucht. Wo der Fluss ins Meer mündet, leben Krokodile und Flussdelfine.',
},
{
id: 'tiefland',
name: 'Tiefland-Regenwald',
emoji: '🌴',
bgFrom: '#5a9a6a',
bgTo: '#3a7a4a',
description: 'Dichter Dschungel, fast kein Licht am Boden. Hier leben die meisten Tiere.',
},
{
id: 'berg',
name: 'Bergregenwald',
emoji: '⛰',
bgFrom: '#4a8a6a',
bgTo: '#2a5a3a',
description: 'Kühl und neblig. Moosbedeckte Bäume, seltene Orchideen, Brüllaffen und Kolibris.',
},
]
+226
View File
@@ -0,0 +1,226 @@
/**
* SIM-12: Flussmanagement — Game Controller
*
* Rundenbasiertes Spiel. Verwaltet Zustand, Runden, Events,
* Scoring und die gesamte Spielschleife.
*/
import {
type Controls, type State, type Conditions, type ScoreBreakdown,
type LevelDefinition, type LevelEvent,
simulateRound, computeScore, computeTotalCost, checkWinLose, checkFinalWin,
LEVELS,
} from './logic'
export type GamePhase = 'level-select' | 'intro' | 'playing' | 'event' | 'round-result' | 'won' | 'lost'
export interface RoundHistory {
round: number
state: State
controls: Controls
score: ScoreBreakdown
cost: number
event?: LevelEvent
}
export class FlussGame {
// Zustand
level!: LevelDefinition
phase: GamePhase = 'level-select'
round = 0
state!: State
controls!: Controls
budgetRemaining = 0
score!: ScoreBreakdown
history: RoundHistory[] = []
currentEvent: LevelEvent | null = null
// Callbacks
private onChange: (() => void) | null = null
constructor() {
this.reset()
}
subscribe(fn: () => void): void {
this.onChange = fn
}
private notify(): void {
this.onChange?.()
}
/** Level auswaehlen und Spiel starten */
selectLevel(levelId: string): void {
const lvl = LEVELS.find(l => l.id === levelId)
if (!lvl) return
this.level = lvl
this.phase = 'intro'
this.round = 0
this.state = { ...lvl.initialState }
this.controls = this.createEmptyControls()
this.budgetRemaining = lvl.conditions.budget
this.score = computeScore(this.state, lvl.scoreWeights)
this.history = []
this.currentEvent = null
this.notify()
}
/** Intro bestaetigt → Spielphase */
startPlaying(): void {
this.phase = 'playing'
this.notify()
}
/** Leere Controls erstellen (nur erlaubte Massnahmen) */
private createEmptyControls(): Controls {
return {
straightening: 0,
levees: 0,
dredging: 0,
floodplainRelease: 0,
renaturation: 0,
irrigation: 0,
}
}
/** Massnahme aendern (Slider) */
setControl(key: keyof Controls, value: number): void {
if (!this.level.allowedControls.includes(key)) return
// Limit pruefen
const limit = this.level.controlLimits?.[key] ?? 100
value = Math.max(0, Math.min(limit, value))
// Budget pruefen: Differenz berechnen
const oldControls = { ...this.controls }
const testControls = { ...this.controls, [key]: value }
const oldCost = computeTotalCost(oldControls)
const newCost = computeTotalCost(testControls)
const costDiff = newCost - oldCost
if (costDiff > this.budgetRemaining + computeTotalCost(this.controls)) {
// Nicht genug Budget — Maximum berechnen
return
}
this.controls[key] = value
this.notify()
}
/** Runde ausfuehren */
executeRound(): void {
if (this.phase !== 'playing') return
if (this.round >= this.level.rounds) return
this.round++
const cost = computeTotalCost(this.controls)
// Budget abziehen
this.budgetRemaining = Math.max(0, this.budgetRemaining - cost)
// Event fuer diese Runde?
const event = this.level.events?.find(e => e.round === this.round) ?? null
this.currentEvent = event
// Simulation ausfuehren
this.state = simulateRound(this.state, this.controls, this.level.conditions, event ?? undefined)
this.score = computeScore(this.state, this.level.scoreWeights)
// History speichern
this.history.push({
round: this.round,
state: { ...this.state },
controls: { ...this.controls },
score: { ...this.score },
cost,
event: event ?? undefined,
})
// Win/Lose pruefen
const result = checkWinLose(this.state, this.score, this.level)
if (result === 'lose') {
this.phase = 'lost'
this.notify()
return
}
// Event anzeigen?
if (event) {
this.phase = 'event'
this.notify()
return
}
// Letzte Runde?
if (this.round >= this.level.rounds) {
this.phase = checkFinalWin(this.score, this.level) ? 'won' : 'lost'
this.notify()
return
}
this.phase = 'round-result'
this.notify()
}
/** Event bestaetigen → weiter spielen */
acknowledgeEvent(): void {
if (this.round >= this.level.rounds) {
this.phase = checkFinalWin(this.score, this.level) ? 'won' : 'lost'
} else {
this.phase = 'playing'
}
this.currentEvent = null
this.notify()
}
/** Rundenresultat bestaetigen → naechste Runde */
continueAfterResult(): void {
this.phase = 'playing'
this.notify()
}
/** Alles zuruecksetzen */
reset(): void {
this.phase = 'level-select'
this.round = 0
this.history = []
this.currentEvent = null
}
/** Serialisieren fuer Save */
serialize(): string {
return JSON.stringify({
v: 1,
levelId: this.level?.id,
phase: this.phase,
round: this.round,
state: this.state,
controls: this.controls,
budgetRemaining: this.budgetRemaining,
history: this.history,
})
}
/** Deserialisieren */
deserialize(json: string): boolean {
try {
const d = JSON.parse(json)
if (d.v !== 1) return false
const lvl = LEVELS.find(l => l.id === d.levelId)
if (!lvl) return false
this.level = lvl
this.phase = d.phase
this.round = d.round
this.state = d.state
this.controls = d.controls
this.budgetRemaining = d.budgetRemaining
this.history = d.history || []
this.score = computeScore(this.state, lvl.scoreWeights)
this.notify()
return true
} catch {
return false
}
}
}
+444
View File
@@ -0,0 +1,444 @@
/**
* SIM-12: Flussmanagement — Simulationslogik
*
* Nichtlineares Simulationsmodell fuer Flussmanagement.
* 6 Steuermassnahmen → 8 Zustandsparameter → 4 Zielbereiche.
*
* Alle Werte liegen im Bereich 0100.
* Abnehmender Grenznutzen, ueberproportionale Nebenwirkungen bei Extremen.
*/
// === Typen ===
export interface Controls {
straightening: number // Flussbegradigung (0100)
levees: number // Daemme/Deiche (0100)
dredging: number // Ausbaggern (0100)
floodplainRelease: number // Auen freigeben (0100)
renaturation: number // Renaturierung (0100)
irrigation: number // Bewaesserung (0100)
}
export interface State {
floodLocal: number // Lokales Hochwasserrisiko
floodDownstream: number // Hochwasser flussabwaerts
erosion: number // Erosionsrisiko
soilFertility: number // Bodenfruchtbarkeit
biodiversity: number // Biodiversitaet
groundwater: number // Grundwasserspiegel
usableLand: number // Nutzbare Flaeche
economy: number // Wirtschaftsleistung
}
export interface Conditions {
rainfall: number // Niederschlag
extremeWeather: number // Extremwetter-Wahrscheinlichkeit
slope: number // Gefaelle
populationPressure: number // Bevoelkerungsdruck
budget: number // Budget (Punkte, nicht 0100)
}
export interface ScoreBreakdown {
safety: number
ecology: number
agriculture: number
economy: number
total: number
}
export interface LevelEvent {
round: number
type: 'flood_event' | 'drought' | 'economic_boost'
intensity: number // 0100
}
export interface LevelDefinition {
id: string
title: string
description: string
durationTargetMinutes: number
rounds: number
initialState: State
conditions: Conditions
allowedControls: (keyof Controls)[]
controlLimits?: Partial<Controls>
scoreWeights: { safety: number; ecology: number; agriculture: number; economy: number }
events?: LevelEvent[]
winConditions: { minScore?: number; targetScores?: Partial<ScoreBreakdown> }
loseConditions?: { maxFloodLocal?: number; maxFloodDownstream?: number; minGroundwater?: number }
}
// === Hilfsfunktionen ===
/** Wert auf 0100 begrenzen */
function clamp(v: number): number {
return Math.max(0, Math.min(100, v))
}
/** Abnehmender Grenznutzen: hohe Intensitaet bringt weniger */
function diminishing(intensity: number): number {
// f(x) = 1 - (1 - x/100)^2 → schneller Anstieg am Anfang, flacher am Ende
const x = intensity / 100
return (1 - Math.pow(1 - x, 2)) * 100
}
/** Ueberproportionale Nebenwirkung bei hoher Intensitaet */
function sideEffect(intensity: number): number {
// f(x) = x^1.8 / 100^0.8 → bei 50: ~35, bei 100: 100
return Math.pow(intensity, 1.8) / Math.pow(100, 0.8)
}
/** Moderater Effekt (linear mit leichtem Bogen) */
function moderate(intensity: number): number {
const x = intensity / 100
return x * 0.7 + x * x * 0.3
}
// === Simulation ===
/**
* Berechnet den neuen Zustand nach einer Runde.
* Kern der nichtlinearen Simulation.
*/
export function simulateRound(
state: State,
controls: Controls,
conditions: Conditions,
event?: LevelEvent
): State {
const s = { ...state }
// --- Basis-Effekte der Rahmenbedingungen ---
const rainFactor = conditions.rainfall / 60 // 1.0 bei normalem Regen
const slopeFactor = conditions.slope / 50 // 1.0 bei normalem Gefaelle
const popFactor = conditions.populationPressure / 50
// --- FLUSSBEGRADIGUNG ---
// + Mehr nutzbare Flaeche, + Wirtschaft
// - Mehr Hochwasser flussabwaerts, - Biodiversitaet, - Grundwasser
if (controls.straightening > 0) {
const eff = diminishing(controls.straightening)
const side = sideEffect(controls.straightening)
s.usableLand = clamp(s.usableLand + eff * 0.15)
s.economy = clamp(s.economy + eff * 0.08)
s.floodLocal = clamp(s.floodLocal - eff * 0.08)
s.floodDownstream = clamp(s.floodDownstream + side * 0.25 * rainFactor)
s.biodiversity = clamp(s.biodiversity - side * 0.18)
s.groundwater = clamp(s.groundwater - moderate(controls.straightening) * 12)
s.erosion = clamp(s.erosion + side * 0.12 * slopeFactor)
}
// --- DAEMME/DEICHE ---
// + Lokaler Hochwasserschutz
// - Flussabwaerts schlimmer, - Grundwasser (Fluss vom Umland getrennt)
if (controls.levees > 0) {
const eff = diminishing(controls.levees)
const side = sideEffect(controls.levees)
s.floodLocal = clamp(s.floodLocal - eff * 0.3)
s.floodDownstream = clamp(s.floodDownstream + side * 0.15)
s.groundwater = clamp(s.groundwater - moderate(controls.levees) * 8)
s.soilFertility = clamp(s.soilFertility - side * 0.06)
s.biodiversity = clamp(s.biodiversity - side * 0.05)
}
// --- AUSBAGGERN ---
// + Tieferer Fluss = weniger lokales Hochwasser, + Schifffahrt/Wirtschaft
// - Erosion, - Biodiversitaet, temporaerer Effekt
if (controls.dredging > 0) {
const eff = diminishing(controls.dredging)
const side = sideEffect(controls.dredging)
s.floodLocal = clamp(s.floodLocal - eff * 0.15)
s.economy = clamp(s.economy + eff * 0.1)
s.erosion = clamp(s.erosion + side * 0.3 * slopeFactor)
s.biodiversity = clamp(s.biodiversity - side * 0.15)
s.groundwater = clamp(s.groundwater - moderate(controls.dredging) * 6)
}
// --- AUEN FREIGEBEN ---
// + Reduziert Hochwasser (Retentionsflaeche), + Grundwasser, + Biodiversitaet
// - Weniger nutzbare Flaeche, - Wirtschaft
if (controls.floodplainRelease > 0) {
const eff = diminishing(controls.floodplainRelease)
const side = sideEffect(controls.floodplainRelease)
s.floodLocal = clamp(s.floodLocal - eff * 0.2)
s.floodDownstream = clamp(s.floodDownstream - eff * 0.15)
s.groundwater = clamp(s.groundwater + eff * 0.15)
s.biodiversity = clamp(s.biodiversity + eff * 0.12)
s.soilFertility = clamp(s.soilFertility + eff * 0.05)
s.usableLand = clamp(s.usableLand - side * 0.2)
s.economy = clamp(s.economy - side * 0.08)
}
// --- RENATURIERUNG ---
// + Biodiversitaet, + Grundwasser, + Bodenfruchtbarkeit, + Erosionsschutz
// - Nutzbare Flaeche, - Wirtschaft, hohe Kosten
if (controls.renaturation > 0) {
const eff = diminishing(controls.renaturation)
const side = sideEffect(controls.renaturation)
s.biodiversity = clamp(s.biodiversity + eff * 0.25)
s.groundwater = clamp(s.groundwater + eff * 0.12)
s.soilFertility = clamp(s.soilFertility + eff * 0.1)
s.erosion = clamp(s.erosion - eff * 0.15)
s.floodLocal = clamp(s.floodLocal - eff * 0.08)
s.floodDownstream = clamp(s.floodDownstream - eff * 0.08)
s.usableLand = clamp(s.usableLand - side * 0.15)
s.economy = clamp(s.economy - side * 0.1)
}
// --- BEWAESSERUNG ---
// + Bodenfruchtbarkeit, + Wirtschaft (Landwirtschaftsertrag)
// - Grundwasser (Entnahme), - bei Extreme: Versalzung
if (controls.irrigation > 0) {
const eff = diminishing(controls.irrigation)
const side = sideEffect(controls.irrigation)
s.soilFertility = clamp(s.soilFertility + eff * 0.18)
s.economy = clamp(s.economy + eff * 0.08)
s.groundwater = clamp(s.groundwater - side * 0.2)
// Versalzung bei extremer Bewaesserung in trockenem Klima
if (controls.irrigation > 70 && conditions.rainfall < 40) {
s.soilFertility = clamp(s.soilFertility - side * 0.12)
}
}
// --- Natuerliche Dynamik ---
// Regen hebt Grundwasser, Erosion verschlechtert Bodenqualitaet
s.groundwater = clamp(s.groundwater + (rainFactor - 1) * 3)
s.soilFertility = clamp(s.soilFertility - s.erosion * 0.02)
// Bevoelkerungsdruck erhoeht Wirtschaftsbedarf, reduziert Biodiversitaet
s.economy = clamp(s.economy + (popFactor - 1) * 2)
s.biodiversity = clamp(s.biodiversity - (popFactor - 1) * 1.5)
// --- Events ---
if (event) {
if (event.type === 'flood_event') {
const floodForce = event.intensity / 100
s.floodLocal = clamp(s.floodLocal + 25 * floodForce * rainFactor)
s.floodDownstream = clamp(s.floodDownstream + 20 * floodForce)
s.erosion = clamp(s.erosion + 15 * floodForce * slopeFactor)
s.usableLand = clamp(s.usableLand - 10 * floodForce)
s.economy = clamp(s.economy - 8 * floodForce)
}
if (event.type === 'drought') {
const droughtForce = event.intensity / 100
s.groundwater = clamp(s.groundwater - 20 * droughtForce)
s.soilFertility = clamp(s.soilFertility - 12 * droughtForce)
s.biodiversity = clamp(s.biodiversity - 8 * droughtForce)
s.floodLocal = clamp(s.floodLocal - 10 * droughtForce) // weniger Hochwasser
}
if (event.type === 'economic_boost') {
const boostForce = event.intensity / 100
s.economy = clamp(s.economy + 15 * boostForce)
s.populationPressure = clamp((conditions.populationPressure || 50) + 10 * boostForce)
}
}
return s
}
/**
* Kosten einer Massnahme berechnen (abhaengig von Intensitaet).
* Hoehere Intensitaet = ueberproportional teurer.
*/
export function computeCost(control: keyof Controls, intensity: number): number {
const baseCosts: Record<keyof Controls, number> = {
straightening: 25,
levees: 20,
dredging: 15,
floodplainRelease: 10,
renaturation: 30,
irrigation: 18,
}
const base = baseCosts[control]
// Kosten steigen quadratisch: cost(50) = ~50% des Maximums, cost(100) = 100%
return Math.round(base * Math.pow(intensity / 100, 1.5))
}
/**
* Gesamtkosten aller aktiven Massnahmen berechnen.
*/
export function computeTotalCost(controls: Controls): number {
let total = 0
for (const key of Object.keys(controls) as (keyof Controls)[]) {
if (controls[key] > 0) {
total += computeCost(key, controls[key])
}
}
return total
}
/**
* Score berechnen (0100 pro Bereich + Gesamtscore).
*/
export function computeScore(
state: State,
weights: { safety: number; ecology: number; agriculture: number; economy: number }
): ScoreBreakdown {
// Sicherheit: niedrige Hochwasser + niedrige Erosion
const safety = clamp(100 - (state.floodLocal * 0.4 + state.floodDownstream * 0.35 + state.erosion * 0.25))
// Oekologie: hohe Biodiversitaet + hoher Grundwasserspiegel
const ecology = clamp(state.biodiversity * 0.6 + state.groundwater * 0.4)
// Landwirtschaft: hohe Bodenfruchtbarkeit + genug Flaeche + genug Wasser
const agriculture = clamp(state.soilFertility * 0.5 + state.usableLand * 0.3 + state.groundwater * 0.2)
// Wirtschaft: direkt
const economy = state.economy
const total = clamp(
safety * weights.safety +
ecology * weights.ecology +
agriculture * weights.agriculture +
economy * weights.economy
)
return { safety, ecology, agriculture, economy, total }
}
/**
* Win/Lose-Bedingungen pruefen.
*/
export function checkWinLose(
state: State,
score: ScoreBreakdown,
level: LevelDefinition
): 'win' | 'lose' | 'playing' {
// Lose-Bedingungen
if (level.loseConditions) {
if (level.loseConditions.maxFloodLocal !== undefined && state.floodLocal > level.loseConditions.maxFloodLocal) return 'lose'
if (level.loseConditions.maxFloodDownstream !== undefined && state.floodDownstream > level.loseConditions.maxFloodDownstream) return 'lose'
if (level.loseConditions.minGroundwater !== undefined && state.groundwater < level.loseConditions.minGroundwater) return 'lose'
}
// Win-Bedingungen (nur am Ende relevant, nicht pro Runde)
return 'playing'
}
/**
* Am Ende des Spiels pruefen ob Win-Bedingungen erfuellt sind.
*/
export function checkFinalWin(
score: ScoreBreakdown,
level: LevelDefinition
): boolean {
const wc = level.winConditions
if (wc.minScore !== undefined && score.total < wc.minScore) return false
if (wc.targetScores) {
for (const [key, target] of Object.entries(wc.targetScores)) {
if (score[key as keyof ScoreBreakdown] < (target as number)) return false
}
}
return true
}
// === Level-Definitionen ===
export const LEVELS: LevelDefinition[] = [
{
id: 'L1',
title: 'Fluss und Siedlung',
description: 'Eine kleine Siedlung liegt an einem Fluss und ist regelmäßig von Hochwasser betroffen. Finde einen Weg, die Bewohner zu schützen!',
durationTargetMinutes: 10,
rounds: 5,
initialState: {
floodLocal: 60, floodDownstream: 40, erosion: 30,
soilFertility: 60, biodiversity: 70, groundwater: 55,
usableLand: 40, economy: 40
},
conditions: { rainfall: 60, extremeWeather: 30, slope: 40, populationPressure: 30, budget: 120 },
allowedControls: ['levees', 'floodplainRelease'],
controlLimits: { levees: 60, floodplainRelease: 60 },
scoreWeights: { safety: 0.5, ecology: 0.2, agriculture: 0.15, economy: 0.15 },
winConditions: { minScore: 60 },
loseConditions: { maxFloodLocal: 85 },
},
{
id: 'L2',
title: 'Fruchtbares Tal',
description: 'Ein Tal lebt von fruchtbaren Böden durch regelmäßige Überschwemmungen. Wie nutzt du das Wasser, ohne alles zu riskieren?',
durationTargetMinutes: 20,
rounds: 7,
initialState: {
floodLocal: 55, floodDownstream: 35, erosion: 35,
soilFertility: 75, biodiversity: 65, groundwater: 60,
usableLand: 45, economy: 50
},
conditions: { rainfall: 65, extremeWeather: 40, slope: 35, populationPressure: 40, budget: 150 },
allowedControls: ['levees', 'floodplainRelease', 'irrigation'],
scoreWeights: { safety: 0.25, ecology: 0.2, agriculture: 0.4, economy: 0.15 },
winConditions: { targetScores: { agriculture: 65, safety: 50 } },
},
{
id: 'L3',
title: 'Der gezähmte Fluss',
description: 'Der Fluss soll kontrolliert werden, um Städte und Industrie zu schützen. Doch die Natur schlägt zurück!',
durationTargetMinutes: 25,
rounds: 8,
initialState: {
floodLocal: 50, floodDownstream: 45, erosion: 40,
soilFertility: 55, biodiversity: 50, groundwater: 50,
usableLand: 55, economy: 60
},
conditions: { rainfall: 60, extremeWeather: 45, slope: 50, populationPressure: 70, budget: 180 },
allowedControls: ['levees', 'straightening', 'dredging'],
scoreWeights: { safety: 0.4, ecology: 0.1, agriculture: 0.2, economy: 0.3 },
events: [{ round: 4, type: 'flood_event', intensity: 70 }],
winConditions: { minScore: 65 },
},
{
id: 'L4',
title: 'Fluss im Gleichgewicht',
description: 'Finde eine Balance zwischen Sicherheit, Natur und Nutzung. Alle Ziele zählen gleich!',
durationTargetMinutes: 30,
rounds: 10,
initialState: {
floodLocal: 55, floodDownstream: 50, erosion: 45,
soilFertility: 60, biodiversity: 60, groundwater: 50,
usableLand: 50, economy: 55
},
conditions: { rainfall: 60, extremeWeather: 50, slope: 45, populationPressure: 60, budget: 180 },
allowedControls: ['levees', 'straightening', 'floodplainRelease', 'renaturation', 'irrigation'],
scoreWeights: { safety: 0.25, ecology: 0.25, agriculture: 0.25, economy: 0.25 },
winConditions: { minScore: 70 },
},
{
id: 'L5',
title: 'Nildelta',
description: 'Die jährlichen Überschwemmungen bringen fruchtbare Böden — aber auch Risiken. Schützt du die Ernte oder die Natur?',
durationTargetMinutes: 35,
rounds: 10,
initialState: {
floodLocal: 65, floodDownstream: 40, erosion: 30,
soilFertility: 85, biodiversity: 70, groundwater: 65,
usableLand: 50, economy: 60
},
conditions: { rainfall: 40, extremeWeather: 20, slope: 20, populationPressure: 70, budget: 160 },
allowedControls: ['levees', 'irrigation', 'floodplainRelease'],
scoreWeights: { safety: 0.2, ecology: 0.2, agriculture: 0.45, economy: 0.15 },
winConditions: { targetScores: { agriculture: 75 } },
loseConditions: { minGroundwater: 25 },
},
]
/** Massnahmen-Metadaten fuer UI */
export const CONTROL_META: Record<keyof Controls, { name: string; emoji: string; description: string }> = {
straightening: { name: 'Flussbegradigung', emoji: '📏', description: 'Den Fluss gerade ziehen — mehr Fläche, aber die Natur leidet.' },
levees: { name: 'Deiche bauen', emoji: '🧱', description: 'Schutz vor Hochwasser — aber das Wasser muss irgendwohin.' },
dredging: { name: 'Ausbaggern', emoji: '⛏️', description: 'Den Fluss tiefer machen — gut für Schiffe, schlecht für Ökosysteme.' },
floodplainRelease: { name: 'Auen freigeben', emoji: '🌊', description: 'Dem Fluss Raum geben — weniger Hochwasser, aber weniger Fläche.' },
renaturation: { name: 'Renaturierung', emoji: '🌿', description: 'Die Natur zurückbringen — gut für alles außer die Wirtschaft.' },
irrigation: { name: 'Bewässerung', emoji: '💧', description: 'Felder bewässern — mehr Ertrag, aber das Grundwasser sinkt.' },
}
/** Zustandsparameter-Metadaten fuer UI */
export const STATE_META: Record<keyof State, { name: string; emoji: string; goodDirection: 'low' | 'high' }> = {
floodLocal: { name: 'Hochwasser (lokal)', emoji: '🌊', goodDirection: 'low' },
floodDownstream: { name: 'Hochwasser (flussab.)', emoji: '🌊', goodDirection: 'low' },
erosion: { name: 'Erosion', emoji: '🏜️', goodDirection: 'low' },
soilFertility: { name: 'Bodenfruchtbarkeit', emoji: '🌱', goodDirection: 'high' },
biodiversity: { name: 'Biodiversität', emoji: '🦎', goodDirection: 'high' },
groundwater: { name: 'Grundwasser', emoji: '💧', goodDirection: 'high' },
usableLand: { name: 'Nutzbare Fläche', emoji: '🏘️', goodDirection: 'high' },
economy: { name: 'Wirtschaft', emoji: '💰', goodDirection: 'high' },
}
+352
View File
@@ -0,0 +1,352 @@
/**
* SIM-12: Flussmanagement — Canvas Renderer
*
* Zeichnet eine Landschaft mit Fluss, Vegetation, Siedlung, Auen.
* Alle visuellen Elemente reagieren auf den Simulationszustand.
*/
import type { State, Controls } from './logic'
export class FlussRenderer {
private ctx: CanvasRenderingContext2D
private w: number
private h: number
private time = 0
constructor(private canvas: HTMLCanvasElement) {
this.ctx = canvas.getContext('2d')!
this.w = canvas.width
this.h = canvas.height
}
resize(): void {
const rect = this.canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.canvas.width = rect.width * dpr
this.canvas.height = rect.height * dpr
this.ctx.scale(dpr, dpr)
this.w = rect.width
this.h = rect.height
}
render(state: State, controls: Controls): void {
this.time += 0.02
const ctx = this.ctx
const w = this.w
const h = this.h
// Hintergrund — Himmel
const skyGrad = ctx.createLinearGradient(0, 0, 0, h * 0.4)
skyGrad.addColorStop(0, '#87CEEB')
skyGrad.addColorStop(1, '#B0E0E6')
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, w, h)
// Grund — Erde/Gras
const groundY = h * 0.35
const groundGrad = ctx.createLinearGradient(0, groundY, 0, h)
const greenIntensity = Math.round(80 + state.biodiversity * 0.8)
groundGrad.addColorStop(0, `rgb(${120 - state.soilFertility * 0.3}, ${greenIntensity}, ${60 - state.erosion * 0.3})`)
groundGrad.addColorStop(1, `rgb(${140 - state.soilFertility * 0.2}, ${100 + state.biodiversity * 0.4}, ${70})`)
ctx.fillStyle = groundGrad
ctx.fillRect(0, groundY, w, h - groundY)
// Berge im Hintergrund
this.drawMountains(ctx, w, h)
// Auen-Bereich (wenn freigegeben)
if (controls.floodplainRelease > 10) {
this.drawFloodplains(ctx, w, h, controls.floodplainRelease, state)
}
// Fluss zeichnen
this.drawRiver(ctx, w, h, state, controls)
// Deiche
if (controls.levees > 10) {
this.drawLevees(ctx, w, h, controls.levees)
}
// Hochwasser-Overlay
if (state.floodLocal > 50) {
this.drawFlood(ctx, w, h, state.floodLocal)
}
// Vegetation / Baeume (Biodiversitaet)
this.drawVegetation(ctx, w, h, state.biodiversity, state.soilFertility)
// Siedlung
this.drawSettlement(ctx, w, h, state.economy, state.usableLand)
// Felder (Landwirtschaft)
this.drawFarms(ctx, w, h, state.soilFertility, controls.irrigation)
// Erosionsspuren
if (state.erosion > 40) {
this.drawErosion(ctx, w, h, state.erosion)
}
// Wolken
this.drawClouds(ctx, w, h)
}
private drawMountains(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const baseY = h * 0.35
ctx.fillStyle = '#8BA89A'
ctx.beginPath()
ctx.moveTo(0, baseY)
ctx.lineTo(w * 0.1, baseY - h * 0.15)
ctx.lineTo(w * 0.2, baseY - h * 0.08)
ctx.lineTo(w * 0.35, baseY - h * 0.2)
ctx.lineTo(w * 0.5, baseY - h * 0.05)
ctx.lineTo(w * 0.65, baseY - h * 0.18)
ctx.lineTo(w * 0.8, baseY - h * 0.1)
ctx.lineTo(w * 0.9, baseY - h * 0.14)
ctx.lineTo(w, baseY)
ctx.closePath()
ctx.fill()
// Schneedecke
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.beginPath()
ctx.moveTo(w * 0.33, baseY - h * 0.2)
ctx.lineTo(w * 0.35, baseY - h * 0.2)
ctx.lineTo(w * 0.37, baseY - h * 0.17)
ctx.lineTo(w * 0.31, baseY - h * 0.17)
ctx.closePath()
ctx.fill()
}
private drawRiver(ctx: CanvasRenderingContext2D, w: number, h: number, state: State, controls: Controls): void {
const riverWidth = 20 + (100 - controls.straightening) * 0.15
const meander = (100 - controls.straightening) * 0.4 // Maeander-Amplitude
const riverY = h * 0.55
// Flussverlauf (von links nach rechts)
ctx.beginPath()
ctx.moveTo(0, riverY)
const steps = 50
for (let i = 0; i <= steps; i++) {
const x = (i / steps) * w
const progress = i / steps
const wave = Math.sin(progress * Math.PI * 3 + this.time) * meander
const y = riverY + wave
ctx.lineTo(x, y)
}
// Untere Kante (Flussbreite)
for (let i = steps; i >= 0; i--) {
const x = (i / steps) * w
const progress = i / steps
const wave = Math.sin(progress * Math.PI * 3 + this.time) * meander
const y = riverY + wave + riverWidth
ctx.lineTo(x, y)
}
ctx.closePath()
// Flussfarbe: klar (biodiversitaet hoch) bis trueb (erosion hoch)
const clarity = Math.max(0, Math.min(1, (state.biodiversity - state.erosion * 0.5) / 80))
const r = Math.round(30 + (1 - clarity) * 60)
const g = Math.round(100 + clarity * 50)
const b = Math.round(160 + clarity * 40)
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
ctx.fill()
// Wasseroberflaeche Glanz
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
ctx.lineWidth = 1
for (let i = 0; i < 5; i++) {
const sx = Math.random() * w
const sy = riverY + Math.sin(sx / w * Math.PI * 3 + this.time) * meander + riverWidth * 0.3
ctx.beginPath()
ctx.moveTo(sx, sy)
ctx.lineTo(sx + 15 + Math.random() * 20, sy - 1)
ctx.stroke()
}
}
private drawLevees(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number): void {
const leveeH = 3 + intensity * 0.08
const riverY = h * 0.55
ctx.fillStyle = '#8B7355'
// Oberer Deich
ctx.fillRect(0, riverY - leveeH - 5, w, leveeH)
// Unterer Deich
ctx.fillRect(0, riverY + 30, w, leveeH)
}
private drawFloodplains(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number, state: State): void {
const riverY = h * 0.55
const extent = intensity * 0.3
const alpha = 0.15 + intensity * 0.002
ctx.fillStyle = `rgba(100, 180, 140, ${alpha})`
// Aue oben
ctx.fillRect(0, riverY - extent - 20, w, extent)
// Aue unten
ctx.fillRect(0, riverY + 35, w, extent)
// Schilf in den Auen
if (intensity > 30) {
ctx.fillStyle = '#5A8A3E'
for (let i = 0; i < intensity * 0.3; i++) {
const x = (i * 47 + 20) % w
const y = riverY - 25 - Math.random() * extent * 0.5
this.drawReeds(ctx, x, y)
}
}
}
private drawReeds(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.save()
ctx.strokeStyle = '#4A7A2E'
ctx.lineWidth = 1.5
for (let i = 0; i < 3; i++) {
const lean = Math.sin(this.time * 2 + x * 0.1) * 3
ctx.beginPath()
ctx.moveTo(x + i * 3, y)
ctx.quadraticCurveTo(x + i * 3 + lean, y - 8, x + i * 3 + lean * 1.5, y - 15)
ctx.stroke()
}
ctx.restore()
}
private drawFlood(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number): void {
const alpha = Math.min(0.4, (intensity - 50) / 100)
const extent = (intensity - 50) * 0.8
const riverY = h * 0.55
ctx.fillStyle = `rgba(70, 130, 180, ${alpha})`
// Ueberflutung breitet sich vom Fluss aus
ctx.fillRect(0, riverY - extent, w, extent * 2 + 30)
// Wellenlinien
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.5})`
ctx.lineWidth = 1
for (let row = 0; row < 3; row++) {
ctx.beginPath()
const baseY = riverY - extent + row * extent * 0.6
for (let x = 0; x < w; x += 5) {
const y = baseY + Math.sin(x * 0.05 + this.time * 3 + row) * 3
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.stroke()
}
}
private drawVegetation(ctx: CanvasRenderingContext2D, w: number, h: number, biodiversity: number, fertility: number): void {
const treeCount = Math.floor(biodiversity * 0.15)
const groundY = h * 0.35
for (let i = 0; i < treeCount; i++) {
const seed = i * 137.5 // goldener Winkel fuer Verteilung
const x = (seed % w)
const yBase = groundY + 10 + (seed * 7 % (h * 0.15))
// Nur Baeume die nicht im Flussbereich sind
if (yBase > h * 0.5 && yBase < h * 0.65) continue
const treeH = 12 + (fertility * 0.1) + (i % 5) * 2
const green = Math.round(60 + biodiversity * 0.8 + (i % 3) * 20)
// Stamm
ctx.fillStyle = '#6B4423'
ctx.fillRect(x - 1.5, yBase - treeH * 0.4, 3, treeH * 0.4)
// Krone
ctx.fillStyle = `rgb(${40 + (i % 20)}, ${green}, ${30 + (i % 15)})`
ctx.beginPath()
ctx.arc(x, yBase - treeH * 0.5, treeH * 0.35, 0, Math.PI * 2)
ctx.fill()
}
}
private drawSettlement(ctx: CanvasRenderingContext2D, w: number, h: number, economy: number, usableLand: number): void {
const houseCount = Math.floor(3 + economy * 0.08)
const startX = w * 0.6
const baseY = h * 0.42
for (let i = 0; i < houseCount; i++) {
const x = startX + (i % 4) * 30 + Math.floor(i / 4) * 15
const y = baseY + Math.floor(i / 4) * 20
const houseH = 12 + (economy * 0.05)
if (x > w - 20) continue
// Haus
ctx.fillStyle = i < 3 ? '#D4A574' : '#C4956A'
ctx.fillRect(x, y - houseH, 16, houseH)
// Dach
ctx.fillStyle = '#8B4513'
ctx.beginPath()
ctx.moveTo(x - 3, y - houseH)
ctx.lineTo(x + 8, y - houseH - 8)
ctx.lineTo(x + 19, y - houseH)
ctx.closePath()
ctx.fill()
// Fenster
ctx.fillStyle = '#FFF8DC'
ctx.fillRect(x + 3, y - houseH + 3, 4, 4)
ctx.fillRect(x + 9, y - houseH + 3, 4, 4)
}
}
private drawFarms(ctx: CanvasRenderingContext2D, w: number, h: number, fertility: number, irrigation: number): void {
const farmArea = h * 0.75
const rows = Math.floor(3 + fertility * 0.04)
for (let r = 0; r < rows; r++) {
const y = farmArea + r * 12
const x = w * 0.05 + r * 20
// Feldstreifen
const green = Math.round(100 + fertility * 0.8)
const brown = Math.round(180 - fertility * 0.5)
ctx.fillStyle = r % 2 === 0
? `rgb(${brown}, ${green}, 60)`
: `rgb(${brown - 20}, ${Math.min(180, green + 20)}, 40)`
ctx.fillRect(x, y, w * 0.25, 8)
// Bewaesserungskanaele
if (irrigation > 20) {
ctx.strokeStyle = 'rgba(70, 130, 200, 0.4)'
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(x, y + 4)
ctx.lineTo(x + w * 0.25, y + 4)
ctx.stroke()
ctx.setLineDash([])
}
}
}
private drawErosion(ctx: CanvasRenderingContext2D, w: number, h: number, erosion: number): void {
const count = Math.floor((erosion - 40) * 0.2)
ctx.fillStyle = 'rgba(139, 115, 85, 0.3)'
for (let i = 0; i < count; i++) {
const x = (i * 89 + 30) % w
const y = h * 0.6 + (i * 43 % (h * 0.2))
ctx.beginPath()
ctx.ellipse(x, y, 8 + erosion * 0.05, 3, 0.3, 0, Math.PI * 2)
ctx.fill()
}
}
private drawClouds(ctx: CanvasRenderingContext2D, w: number, h: number): void {
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
for (let i = 0; i < 3; i++) {
const cx = (i * 250 + this.time * 15) % (w + 100) - 50
const cy = 30 + i * 25
ctx.beginPath()
ctx.arc(cx, cy, 20, 0, Math.PI * 2)
ctx.arc(cx + 15, cy - 5, 15, 0, Math.PI * 2)
ctx.arc(cx + 30, cy, 18, 0, Math.PI * 2)
ctx.arc(cx - 12, cy + 2, 14, 0, Math.PI * 2)
ctx.fill()
}
}
}
+110
View File
@@ -0,0 +1,110 @@
/* ========================================
GeoGraSim Base Styles
======================================== */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap');
@import './tokens.css';
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* --- Typografie --- */
h1, h2, h3, h4 { font-weight: var(--font-weight-black); line-height: 1.2; }
h1 { font-size: var(--font-size-xxl); }
h2 { font-size: var(--font-size-xl); }
h3 { font-size: var(--font-size-lg); }
h4 { font-size: var(--font-size-md); }
p { margin-bottom: var(--space-md); }
a { color: var(--color-fjord); text-decoration: none; }
a:hover { text-decoration: underline; }
/* --- Container --- */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-lg);
}
/* --- Card --- */
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
transition: transform var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out);
}
.card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
}
/* --- Button --- */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 0.6rem 1.4rem;
border-radius: var(--radius-md);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
border: none;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
text-decoration: none;
}
.btn-primary {
background: var(--color-fjord);
color: #fff;
}
.btn-primary:hover {
background: #3d6b78;
transform: translateY(-1px);
text-decoration: none;
}
.btn-secondary {
background: var(--color-surface-alt);
color: var(--color-text);
border: 1px solid rgba(0,0,0,0.08);
}
.btn-secondary:hover {
background: var(--color-bg-warm);
}
/* --- Badge / Tag --- */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
}
.tag-k1 { background: var(--color-fjord-light); color: var(--color-fjord); }
.tag-k2 { background: var(--color-moss-light); color: var(--color-moss); }
.tag-k3 { background: var(--color-sand-light); color: var(--color-sand); }
.tag-k4 { background: var(--color-coral-light); color: var(--color-coral); }
/* --- Section spacing --- */
section { padding: var(--space-xxl) 0; }
.section-label {
display: inline-block;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-fjord);
margin-bottom: var(--space-sm);
}
+90
View File
@@ -0,0 +1,90 @@
/* ========================================
GeoGraSim Design Tokens
Stil: Scandinavian Minimal
======================================== */
:root {
/* --- Farben: Gedeckte Naturpalette --- */
--color-bg: #fafaf8;
--color-bg-warm: #f5f3ef;
--color-surface: #ffffff;
--color-surface-alt: #f0eeea;
--color-text: #1a1a1a;
--color-text-secondary: #5a5a5a;
--color-text-muted: #8a8a8a;
/* Akzentfarben — gedeckt, natürlich */
--color-fjord: #4a7c8a; /* Petrol/Fjord-Blau */
--color-fjord-light: #dae8ec;
--color-moss: #5a8a5e; /* Moos-Grün */
--color-moss-light: #dceadd;
--color-sand: #c4a35a; /* Sandstein */
--color-sand-light: #f2eacc;
--color-coral: #c07a6b; /* Gedecktes Korall */
--color-coral-light: #f2ddd8;
--color-slate: #6a6a7a; /* Schiefer */
--color-slate-light: #e2e2e8;
/* Klassen-Farben */
--color-k1: #4a7c8a; /* 1. Klasse: Fjord */
--color-k2: #5a8a5e; /* 2. Klasse: Moos */
--color-k3: #c4a35a; /* 3. Klasse: Sand */
--color-k4: #c07a6b; /* 4. Klasse: Korall */
/* Feedback */
--color-success: #5a8a5e;
--color-warning: #c4a35a;
--color-error: #b55a4a;
--color-info: #4a7c8a;
/* --- Typografie --- */
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
--font-size-base: 0.95rem;
--font-size-md: 1.1rem;
--font-size-lg: 1.4rem;
--font-size-xl: 2rem;
--font-size-xxl: 3rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 600;
--font-weight-black: 800;
--line-height: 1.65;
--letter-spacing: -0.01em;
/* --- Spacing --- */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.5rem;
--space-xxl: 4rem;
/* --- Radien --- */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 999px;
/* --- Schatten --- */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
--shadow-card: 0 2px 8px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.03);
/* --- Animation --- */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 600ms;
--duration-scenic: 20s; /* Für Deko-Animationen */
/* --- Layout --- */
--max-width: 1140px;
--nav-height: 60px;
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Scenic Background — Skandinavische Landschaftsanimation
*
* Erzeugt eine sanfte, minimalistische Hintergrundszene mit:
* - Sanften Hügeln / Fjord-Silhouette
* - Segelboot das langsam vorbeifährt
* - Wolken die ziehen
* - Vögel die fliegen
* - Subtile Wellenbewegung
*
* Alles in gedeckten Naturfarben, leicht und beruhigend.
*/
interface ScenicElement {
x: number
y: number
speed: number
size: number
opacity: number
}
interface Cloud extends ScenicElement {
width: number
}
interface Bird extends ScenicElement {
wingPhase: number
wingSpeed: number
}
interface Boat extends ScenicElement {
bobPhase: number
}
interface Wave {
offset: number
amplitude: number
frequency: number
speed: number
}
export class ScenicBackground {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private width = 0
private height = 0
private time = 0
private animId = 0
private clouds: Cloud[] = []
private birds: Bird[] = []
private boat: Boat
private waves: Wave[] = []
// Farben — skandinavisch gedeckt
private colors = {
sky: '#e8ebe6',
skyBottom: '#d4ddd6',
water: '#9ab5b8',
waterDeep: '#7a9ea2',
mountain: '#8a9a8c',
mountainFar:'#b0bab2',
land: '#a4b09a',
cloud: '#ffffff',
boat: '#c07a6b',
boatSail: '#f0eeea',
bird: '#5a5a5a',
}
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'
container.style.position = 'relative'
container.prepend(this.canvas)
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('Canvas 2D not supported')
this.ctx = ctx
this.boat = { x: -100, y: 0, speed: 0.3, size: 1, opacity: 0.7, bobPhase: 0 }
this.resize()
this.initElements()
window.addEventListener('resize', () => this.resize())
}
private resize(): void {
const rect = this.canvas.parentElement!.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
this.width = rect.width
this.height = rect.height
this.canvas.width = this.width * dpr
this.canvas.height = this.height * dpr
this.ctx.scale(dpr, dpr)
}
private initElements(): void {
// Wolken
this.clouds = Array.from({ length: 5 }, () => ({
x: Math.random() * this.width * 1.5,
y: this.height * (0.05 + Math.random() * 0.15),
speed: 0.1 + Math.random() * 0.2,
size: 0.6 + Math.random() * 0.6,
opacity: 0.3 + Math.random() * 0.4,
width: 60 + Math.random() * 80,
}))
// Vögel
this.birds = Array.from({ length: 3 }, () => ({
x: Math.random() * this.width,
y: this.height * (0.1 + Math.random() * 0.2),
speed: 0.4 + Math.random() * 0.3,
size: 4 + Math.random() * 4,
opacity: 0.3 + Math.random() * 0.2,
wingPhase: Math.random() * Math.PI * 2,
wingSpeed: 3 + Math.random() * 2,
}))
// Wellen
this.waves = Array.from({ length: 3 }, (_, i) => ({
offset: 0,
amplitude: 2 + i * 1.5,
frequency: 0.008 + i * 0.003,
speed: 0.3 + i * 0.15,
}))
}
start(): void {
const animate = () => {
this.time += 0.016
this.update()
this.draw()
this.animId = requestAnimationFrame(animate)
}
animate()
}
stop(): void {
cancelAnimationFrame(this.animId)
}
private update(): void {
// Wolken bewegen
for (const c of this.clouds) {
c.x += c.speed
if (c.x > this.width + c.width) {
c.x = -c.width * 2
c.y = this.height * (0.05 + Math.random() * 0.15)
}
}
// Vögel bewegen
for (const b of this.birds) {
b.x += b.speed
b.wingPhase += b.wingSpeed * 0.016
b.y += Math.sin(this.time * 0.5 + b.wingPhase) * 0.15
if (b.x > this.width + 50) {
b.x = -50
b.y = this.height * (0.1 + Math.random() * 0.2)
}
}
// Boot bewegen
this.boat.x += this.boat.speed
this.boat.bobPhase += 0.02
if (this.boat.x > this.width + 100) {
this.boat.x = -100
}
// Wellen
for (const w of this.waves) {
w.offset += w.speed
}
}
private draw(): void {
const { ctx, width: w, height: h } = this
const waterLine = h * 0.55
ctx.clearRect(0, 0, w, h)
// Himmel — sanfter Gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, waterLine)
skyGrad.addColorStop(0, this.colors.sky)
skyGrad.addColorStop(1, this.colors.skyBottom)
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, w, waterLine)
// Ferne Berge
this.drawMountains(ctx, w, waterLine, this.colors.mountainFar, 0.35, 0.08)
this.drawMountains(ctx, w, waterLine, this.colors.mountain, 0.45, 0.12)
// Wolken
for (const c of this.clouds) {
this.drawCloud(ctx, c)
}
// Vögel
for (const b of this.birds) {
this.drawBird(ctx, b)
}
// Wasser
const waterGrad = ctx.createLinearGradient(0, waterLine, 0, h)
waterGrad.addColorStop(0, this.colors.water)
waterGrad.addColorStop(1, this.colors.waterDeep)
ctx.fillStyle = waterGrad
ctx.fillRect(0, waterLine, w, h - waterLine)
// Wellen auf dem Wasser
for (const wave of this.waves) {
ctx.beginPath()
ctx.moveTo(0, waterLine)
for (let x = 0; x <= w; x += 3) {
const y = waterLine + Math.sin(x * wave.frequency + wave.offset * 0.01) * wave.amplitude
ctx.lineTo(x, y)
}
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.closePath()
ctx.fillStyle = `rgba(255,255,255,0.04)`
ctx.fill()
}
// Boot
const boatY = waterLine - 8 + Math.sin(this.boat.bobPhase) * 2.5
this.drawBoat(ctx, this.boat.x, boatY)
// Sanfter Fade nach unten (damit Text darüber lesbar bleibt)
const fadeGrad = ctx.createLinearGradient(0, h * 0.7, 0, h)
fadeGrad.addColorStop(0, 'rgba(250,250,248,0)')
fadeGrad.addColorStop(1, 'rgba(250,250,248,1)')
ctx.fillStyle = fadeGrad
ctx.fillRect(0, h * 0.7, w, h * 0.3)
}
private drawMountains(ctx: CanvasRenderingContext2D, w: number, baseline: number, color: string, heightFactor: number, roughness: number): void {
ctx.beginPath()
ctx.moveTo(0, baseline)
const segments = 8
const segW = w / segments
for (let i = 0; i <= segments; i++) {
const x = i * segW
const peakH = baseline * heightFactor * (0.5 + Math.sin(i * 1.3 + 0.5) * 0.5)
const y = baseline - peakH + Math.sin(i * 2.7) * baseline * roughness
if (i === 0) {
ctx.lineTo(x, y)
} else {
const cpx = x - segW * 0.5
const cpy = y - baseline * roughness * 0.3
ctx.quadraticCurveTo(cpx, cpy, x, y)
}
}
ctx.lineTo(w, baseline)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
}
private drawCloud(ctx: CanvasRenderingContext2D, c: Cloud): void {
ctx.globalAlpha = c.opacity
ctx.fillStyle = this.colors.cloud
const cx = c.x
const cy = c.y
const s = c.size
// Wolke aus überlappenden Ellipsen
ctx.beginPath()
ctx.ellipse(cx, cy, 30 * s, 12 * s, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(cx - 18 * s, cy + 2 * s, 20 * s, 10 * s, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(cx + 20 * s, cy + 3 * s, 22 * s, 9 * s, 0, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1
}
private drawBird(ctx: CanvasRenderingContext2D, b: Bird): void {
const wingAngle = Math.sin(b.wingPhase) * 0.4
ctx.globalAlpha = b.opacity
ctx.strokeStyle = this.colors.bird
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(b.x - b.size, b.y - wingAngle * b.size)
ctx.quadraticCurveTo(b.x, b.y + 1, b.x + b.size, b.y - wingAngle * b.size)
ctx.stroke()
ctx.globalAlpha = 1
}
private drawBoat(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.globalAlpha = this.boat.opacity
const s = 0.8
// Rumpf
ctx.fillStyle = this.colors.boat
ctx.beginPath()
ctx.moveTo(x - 20 * s, y)
ctx.lineTo(x - 16 * s, y + 8 * s)
ctx.lineTo(x + 16 * s, y + 8 * s)
ctx.lineTo(x + 20 * s, y)
ctx.closePath()
ctx.fill()
// Mast
ctx.strokeStyle = '#5a5a5a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x, y - 22 * s)
ctx.stroke()
// Segel
ctx.fillStyle = this.colors.boatSail
ctx.beginPath()
ctx.moveTo(x + 1, y - 20 * s)
ctx.lineTo(x + 14 * s, y - 4 * s)
ctx.lineTo(x + 1, y - 2 * s)
ctx.closePath()
ctx.fill()
ctx.globalAlpha = 1
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Simulation Shell — Der Rahmen um jede Simulation
*
* Enthält:
* - Header mit Titel und Metadaten
* - POE-Phase-Indikator (Predict → Observe → Explain)
* - Variablen-Regler
* - Canvas-Container für die Visualisierung
* - Reflexions-Panel
*/
import { Simulation } from '@core/simulation'
import { AUSTRIA, getLocalName } from '@core/education-levels'
export class SimShell {
private container: HTMLElement
private sim: Simulation
private canvasContainer: HTMLElement
private controlsContainer: HTMLElement
constructor(hostElement: HTMLElement, sim: Simulation) {
this.sim = sim
this.container = document.createElement('div')
this.container.className = 'sim-shell'
this.container.innerHTML = this.buildHTML()
hostElement.appendChild(this.container)
this.canvasContainer = this.container.querySelector('.sim-canvas-area')!
this.controlsContainer = this.container.querySelector('.sim-controls')!
this.buildControls()
this.updatePhaseUI()
}
getCanvasContainer(): HTMLElement {
return this.canvasContainer
}
private buildHTML(): string {
const m = this.sim.meta
const localName = getLocalName(AUSTRIA, m.primaryLevel) || `Stufe ${m.primaryLevel}`
return `
<style>
.sim-shell { max-width: 900px; margin: 0 auto; padding: 1rem; }
.sim-header { margin-bottom: 1rem; }
.sim-header h2 { font-size: 1.4rem; font-weight: 800; letter-spacing: -.02em; margin-bottom: .2rem; }
.sim-header-meta { display: flex; gap: .4rem; flex-wrap: wrap; }
.sim-meta-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
.sim-meta-klasse { background: #dae8ec; color: #4a7c8a; }
.sim-meta-zeit { background: #f0eeea; color: #6a6a6a; }
.sim-meta-typ { background: #dceadd; color: #5a8a5e; }
.sim-poe { display: flex; gap: .3rem; margin-bottom: 1rem; }
.sim-poe-step { flex: 1; padding: .5rem; border-radius: 8px; text-align: center; font-size: .72rem; font-weight: 600; background: #f0eeea; color: #8a8a8a; transition: all .3s; }
.sim-poe-step.active { background: #4a7c8a; color: #fff; }
.sim-poe-step.done { background: #dceadd; color: #5a8a5e; }
.sim-body { display: grid; grid-template-columns: 1fr 240px; gap: 1rem; }
.sim-canvas-area { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); overflow: hidden; min-height: 300px; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
.sim-sidebar { display: flex; flex-direction: column; gap: .8rem; }
.sim-controls { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); padding: 1rem; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
.sim-controls h4 { font-size: .78rem; font-weight: 700; color: #4a7c8a; margin-bottom: .6rem; text-transform: uppercase; letter-spacing: .06em; }
.sim-control { margin-bottom: .8rem; }
.sim-control label { display: flex; justify-content: space-between; font-size: .78rem; font-weight: 500; margin-bottom: .2rem; }
.sim-control label span { color: #8a8a8a; font-weight: 600; }
.sim-control input[type=range] { width: 100%; -webkit-appearance: none; height: 6px; border-radius: 3px; background: #e8e8e4; outline: none; }
.sim-control input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #4a7c8a; cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.15); }
.sim-lernziele { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); padding: 1rem; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
.sim-lernziele h4 { font-size: .78rem; font-weight: 700; color: #5a8a5e; margin-bottom: .4rem; }
.sim-lernziele ul { font-size: .75rem; color: #4a4a4a; padding-left: 1rem; }
.sim-lernziele li { margin-bottom: .2rem; }
.sim-phase-btn { width: 100%; padding: .6rem; border: none; border-radius: 8px; font-weight: 600; font-size: .82rem; cursor: pointer; background: #4a7c8a; color: #fff; transition: all .2s; }
.sim-phase-btn:hover { background: #3a6470; }
@media (max-width: 700px) {
.sim-body { grid-template-columns: 1fr; }
}
</style>
<div class="sim-header">
<h2>${m.name}</h2>
<div class="sim-header-meta">
<span class="sim-meta-tag sim-meta-klasse">${localName}</span>
<span class="sim-meta-tag sim-meta-zeit">⏱ ${m.dpiMinuten} min</span>
<span class="sim-meta-tag sim-meta-typ">${m.typ}</span>
</div>
</div>
<div class="sim-poe">
<div class="sim-poe-step" data-phase="intro">📖 Intro</div>
<div class="sim-poe-step" data-phase="predict">🤔 Predict</div>
<div class="sim-poe-step" data-phase="simulate">🔬 Simulate</div>
<div class="sim-poe-step" data-phase="observe">👁️ Observe</div>
<div class="sim-poe-step" data-phase="reflect">💭 Reflect</div>
</div>
<div class="sim-body">
<div class="sim-canvas-area"></div>
<div class="sim-sidebar">
<div class="sim-controls">
<h4>Parameter</h4>
</div>
<div class="sim-lernziele">
<h4>🎯 Lernziele</h4>
<ul>${m.lernziele.map(l => `<li>${l}</li>`).join('')}</ul>
</div>
<button class="sim-phase-btn" onclick="this.closest('.sim-shell').__nextPhase()">
Weiter →
</button>
</div>
</div>
`
// Attach next phase handler
setTimeout(() => {
const btn = this.container.querySelector('.sim-phase-btn') as HTMLButtonElement
if (btn) {
btn.addEventListener('click', () => {
this.sim.nextPhase()
this.updatePhaseUI()
})
}
}, 0)
}
private buildControls(): void {
const ranges = this.sim.getVariableRanges()
const ctrl = this.controlsContainer
for (const [key, range] of Object.entries(ranges)) {
const div = document.createElement('div')
div.className = 'sim-control'
div.innerHTML = `
<label>${range.label} <span class="val">${range.default}${range.unit ? ' ' + range.unit : ''}</span></label>
<input type="range" min="${range.min}" max="${range.max}" value="${range.default}" step="${range.max > 10 ? 1 : 0.01}">
`
const input = div.querySelector('input')!
const valSpan = div.querySelector('.val')!
input.addEventListener('input', () => {
const v = parseFloat(input.value)
this.sim.setVariable(key, v)
valSpan.textContent = `${v}${range.unit ? ' ' + range.unit : ''}`
})
ctrl.appendChild(div)
}
}
private updatePhaseUI(): void {
const phases = ['intro', 'predict', 'simulate', 'observe', 'reflect']
const current = this.sim['state'].phase // accessing protected state
const currentIdx = phases.indexOf(current === 'complete' ? 'reflect' : current)
this.container.querySelectorAll('.sim-poe-step').forEach((el, i) => {
el.classList.remove('active', 'done')
if (i === currentIdx) el.classList.add('active')
else if (i < currentIdx) el.classList.add('done')
})
}
}
+965
View File
@@ -0,0 +1,965 @@
/**
* Game UI — Wiederverwendbare Game-Page-Komponente
*
* Bekommt eine Game-Instance, einen Renderer und eine Konfiguration für
* den Maßnahmen-Shop, und baut die komplette UI auf:
* - Ressourcen-Panel
* - Zielepanel
* - Speed-Controls
* - Tutorial-Overlay
* - Maßnahmen-Shop
* - Events
* - Graphen
* - Save / Load / Finish
* - End-Screen
*/
import type { GameEngine } from '@core/game-engine'
import { INFO_TOPICS, openInfoOverlay } from './info-overlay'
import { persistence } from '@core/persistence'
export interface ShopItem {
id: string
name: string
emoji: string
description: string
cost: number
upkeep?: number
badges?: Array<{ label: string; type: 'cost' | 'reduction' | 'protection' | 'capacity' | 'quality' | 'neutral' }>
}
export interface GraphZone {
/** y-Wert ab dem diese Zone beginnt */
from: number
/** y-Wert bis wo sie geht */
to: number
/** Farbe im RGBA-Format, bevorzugt mit Alpha ~0.15 */
color: string
/** Beschriftung die links in der Zone angezeigt wird */
label?: string
}
export interface GraphLine {
/** y-Wert der Linie */
at: number
/** Farbe */
color: string
/** Text-Label rechts neben der Linie */
label?: string
/** Strichlinie? */
dashed?: boolean
}
export interface GraphConfig {
id: string
title: string
field: string // resource id
yMin: number
yMax: number
color: string
format?: (v: number) => string
/** Horizontale Farbbänder im Hintergrund (z.B. Temperaturzonen) */
zones?: GraphZone[]
/** Horizontale Referenzlinien (z.B. "Klimaziel 17°C") */
lines?: GraphLine[]
/** Einheit für die Anzeige ("°C", "cm", "Mio €", "ppm") */
unit?: string
/**
* Dynamische Skalierung: yMax und optional yMin wandern mit den Daten mit,
* sodass die Kurve immer den Graph füllt. yMin/yMax aus der Config bleiben
* als UNTERGRENZEN (yMax wird bei Bedarf nach oben erweitert).
*/
autoScale?: boolean
/** Kurzer Achsen-Label für die Tick-Zahl (z.B. "°C" leer lassen weil im Titel) */
yTickSuffix?: string
}
export type PlayMode = 'free' | 'guided'
export interface GameUIConfig {
game: GameEngine
Renderer: new (container: HTMLElement, game: any) => { start(): void; stop(): void }
shopItems: ShopItem[]
onBuy: (id: string) => boolean
/** Optional: Maßnahme abreißen (Rückerstattung). Wird im Shop als 🗑 angezeigt. */
onDemolish?: (id: string) => boolean
/** Optional: aktuelle Stückzahl einer Maßnahme abfragen (für "× N"-Badge im Shop) */
getOwnedCount?: (id: string) => number
/**
* Optional: Baueditor-Hook. Wenn gesetzt, wird beim Klick auf eine
* Maßnahme NICHT direkt onBuy aufgerufen, sondern dieser Callback —
* der Renderer startet dann den Placement-Mode (Ghost-Mesh, Raycast).
* Sobald die Anwender*in eine Stelle wählt, ruft der Callback intern
* `game.buyMeasure(id, {x, z})` auf.
*/
onStartPlacement?: (id: string) => void
graphs: GraphConfig[]
saveKey: string
finishOnTick?: number // optional: auto-finish nach diesem Tick
yearOffset?: number // z.B. 2025 oder 1975
/**
* Spielmodus:
* - 'free' (Default): Schüler kann Geschwindigkeit frei wählen
* - 'guided': Lehrperson hat fixe Spieldauer gesetzt; Speed-Buttons ausgeblendet,
* Tick-Dauer wird auf totalDurationSec/maxTicks gerechnet, nur Pause erlaubt
*/
mode?: PlayMode
/** Im 'guided' Mode: Gesamt-Spieldauer in Sekunden, die das Spiel laufen soll */
totalDurationSec?: number
/**
* Optional: Whitelist welche Resource-IDs in der Status-Box angezeigt werden.
* Wenn nicht gesetzt, werden alle Resources angezeigt.
* Werte die in einem Graph stehen, kann man hier weglassen, um Doppelung zu vermeiden.
*/
statusResources?: string[]
}
export class GameUI {
private cfg: GameUIConfig
private game: GameEngine
private renderer: { start(): void; stop(): void; [key: string]: any } | null = null
private endShown = false
private autoSaveTimer = 0
private autoSavePending = false
/**
* Pro Resource ein gedämpft animierter Anzeige-Wert — springt nicht hart
* beim Tick-Wechsel, sondern zählt weich zum neuen Ziel.
*/
private displayedValues: Record<string, number> = {}
private displayAnimRaf = 0
/** True wenn wir das Spiel wegen einer offenen Bürger-Beschwerde pausiert haben */
private pausedForCitizen = false
/** ID der aktuell angezeigten Bürger-Beschwerde — verhindert Re-Render bei Ticks */
private currentCitizenId: string | null = null
/** Wird auf true gesetzt wenn Reset läuft — verhindert, dass beforeunload den Save wiederherstellt */
private resetting = false
constructor(cfg: GameUIConfig) {
this.cfg = cfg
this.game = cfg.game
// Im 'guided' Mode die Tick-Dauer fix setzen, sodass das Spiel genau totalDurationSec dauert.
if (cfg.mode === 'guided' && cfg.totalDurationSec && this.game.meta.maxTicks > 0) {
const msPerTick = (cfg.totalDurationSec * 1000) / this.game.meta.maxTicks
// Direktes Patchen, weil msPerTick im meta sonst readonly wäre
;(this.game.meta as any).msPerTick = msPerTick
}
// Vorhandenen Stand automatisch laden, falls vorhanden
this.tryAutoLoad()
this.bindControls()
this.game.subscribe(() => {
this.renderAll()
this.scheduleAutoSave()
})
this.renderAll()
// Beim Verlassen der Page sofort speichern
window.addEventListener('beforeunload', () => this.flushAutoSave())
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flushAutoSave()
})
}
/** Renderer-Instanz für externe Steuerung (z.B. Kamera-Buttons) */
getRenderer(): { start(): void; stop(): void; [key: string]: any } | null {
return this.renderer
}
// === Auto-Save ===
private tryAutoLoad(): void {
// Synchron aus localStorage laden (schnell), dann async aus API nachladen
const localSave = localStorage.getItem(this.cfg.saveKey)
if (localSave && this.game.deserialize(localSave)) {
// Stand aus localStorage wiederhergestellt
}
// Async: Server-Stand pruefen (ueberschreibt ggf. localStorage-Stand)
if (persistence.hasSession()) {
persistence.load(this.cfg.saveKey).then(serverSave => {
if (serverSave && serverSave !== localSave) {
this.game.deserialize(serverSave)
this.renderAll()
}
})
}
}
private scheduleAutoSave(): void {
// Debounce: spätestens nach 1.5 s schreiben
if (this.autoSavePending) return
this.autoSavePending = true
this.autoSaveTimer = window.setTimeout(() => {
this.flushAutoSave()
}, 1500)
}
private flushAutoSave(): void {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
this.autoSaveTimer = 0
}
this.autoSavePending = false
// Während eines Resets NICHT wieder speichern, sonst bleibt der alte Zustand erhalten
if (this.resetting) return
const data = this.game.serialize()
persistence.save(this.cfg.saveKey, data)
}
private bindControls(): void {
// Speed-Modus durchsetzen: im 'guided' nur Pause/Play (Speed 0 und 1) erlaubt.
// Selector geht auf das ATTRIBUT data-speed (nicht auf eine Klasse), weil
// verschiedene HTML-Seiten verschiedene Button-Klassen verwenden
// (game-3d: .icon-btn, game.html: .speed-btn).
const isGuided = this.cfg.mode === 'guided'
document.querySelectorAll<HTMLButtonElement>('[data-speed]').forEach(btn => {
const speed = parseInt(btn.dataset.speed || '1')
if (isGuided && speed > 1) {
btn.style.display = 'none'
return
}
btn.addEventListener('click', () => {
const s = speed as 0 | 1 | 2 | 4
this.game.setSpeed(s)
document.querySelectorAll('[data-speed]').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
})
})
// Save / Load / Finish
document.getElementById('btn-save')?.addEventListener('click', () => {
persistence.save(this.cfg.saveKey, this.game.serialize())
this.toast('💾 Stand gespeichert')
})
document.getElementById('btn-load')?.addEventListener('click', async () => {
const save = await persistence.load(this.cfg.saveKey)
if (!save) { this.toast('Kein Stand vorhanden'); return }
if (this.game.deserialize(save)) {
this.renderAll()
this.toast('📂 Stand geladen')
}
})
document.getElementById('btn-finish')?.addEventListener('click', () => {
this.game.finish()
this.showEndScreen()
})
document.getElementById('btn-reset')?.addEventListener('click', () => {
if (confirm('Stand wirklich löschen und neu starten?')) {
// Reset-Flag setzt beforeunload-Handler außer Kraft
this.resetting = true
localStorage.removeItem(this.cfg.saveKey)
location.reload()
}
})
// Tutorial next button
document.getElementById('tut-next')?.addEventListener('click', () => {
this.game.nextTutorialStep()
this.renderAll()
})
}
/**
* Erzwingt ein sofortiges Neuzeichnen aller Graphen — wird z. B. nach
* dem Zoom-Animation-End aufgerufen, damit sich die SVGs an die neue
* Card-Größe anpassen.
*/
redrawGraphs(): void {
this.renderGraphs()
}
private renderAll(): void {
this.renderResources()
this.renderGoals()
this.renderShop()
this.renderEvents()
this.renderGraphs()
this.renderTutorial()
this.renderCitizenEvent()
const snap = this.game.getSnapshot()
if ((snap.state === 'won' || snap.state === 'lost') && !this.endShown) {
setTimeout(() => this.showEndScreen(), 800)
}
}
private renderCitizenEvent(): void {
const host = document.getElementById('citizen-event-host')
if (!host) return
const ev = this.game.getPendingCitizenEvent()
// Kein Event → Modal ausblenden, Spiel fortsetzen
if (!ev) {
if (this.currentCitizenId !== null) {
host.innerHTML = ''
this.currentCitizenId = null
}
if (this.pausedForCitizen) {
this.pausedForCitizen = false
this.game.setSpeed(1)
}
return
}
// Gleiches Event wie zuletzt → Modal NICHT neu rendern,
// sonst flackert es bei jedem Tick-Render
if (this.currentCitizenId === ev.id) {
return
}
this.currentCitizenId = ev.id
// Neues Event: Spiel pausieren, Modal einmal aufbauen
if (!this.pausedForCitizen) {
this.pausedForCitizen = true
this.game.setSpeed(0)
}
const choicesHtml = ev.choices.map((c, i) => `
<button class="citizen-choice" data-idx="${i}">
<div class="citizen-choice-label">${c.label}</div>
${c.description ? `<div class="citizen-choice-desc">${c.description}</div>` : ''}
</button>
`).join('')
host.innerHTML = `
<div class="citizen-overlay">
<div class="citizen-card">
<div class="citizen-head">
<div class="citizen-avatar">${ev.character}</div>
<div>
<div class="citizen-title">${ev.title}</div>
<div class="citizen-sub">Eine Bürgerin meldet sich</div>
</div>
</div>
<div class="citizen-message">„${ev.message}"</div>
<div class="citizen-choices">${choicesHtml}</div>
</div>
</div>
`
host.querySelectorAll<HTMLButtonElement>('.citizen-choice').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx || '0')
this.game.resolveCitizenEvent(idx)
})
})
}
private renderResources(): void {
const el = document.getElementById('resources')
if (!el) return
let resources = this.game.getResourcesArray()
if (this.cfg.statusResources && this.cfg.statusResources.length > 0) {
const allow = new Set(this.cfg.statusResources)
resources = resources.filter(r => allow.has(r.id))
}
// HTML-Gerüst einmal bauen (wenn noch nicht da oder Struktur geändert)
const existing = el.querySelectorAll<HTMLElement>('.resource')
const needsRebuild = existing.length !== resources.length
if (needsRebuild) {
el.innerHTML = resources.map(r => {
// Spezialfall STROM: zwei kleine Balken (Erzeugung / Bedarf)
// visualisieren das Strom-Netz didaktisch.
let extra = ''
if (r.id === 'power') {
extra = `
<div class="power-grid">
<div class="pg-row">
<span class="pg-lbl">⚡ Strom</span>
<div class="pg-track"><div class="pg-fill pg-cap"></div></div>
<span class="pg-num pg-cap-num">0</span>
</div>
<div class="pg-row">
<span class="pg-lbl">🏠 Bedarf</span>
<div class="pg-track"><div class="pg-fill pg-dem"></div></div>
<span class="pg-num pg-dem-num">0</span>
</div>
<div class="pg-status pg-status-ok">Versorgung gesichert</div>
</div>
`
} else if (r.id === 'budget' && typeof (this.game as any).getYearlyBalance === 'function') {
// Spezialfall BUDGET: Jahres-Bilanz (Einnahmen / Wartung / Netto)
extra = `
<div class="budget-balance">
<div class="bb-row">
<span class="bb-lbl">+ Steuern</span>
<span class="bb-num bb-pos bb-income">0</span>
</div>
<div class="bb-row bb-tourism" style="display:none">
<span class="bb-lbl">+ Tourismus</span>
<span class="bb-num bb-pos bb-tourism-num">0</span>
</div>
<div class="bb-row">
<span class="bb-lbl"> Wartung</span>
<span class="bb-num bb-neg bb-upkeep">0</span>
</div>
<div class="bb-row bb-climate" style="display:none">
<span class="bb-lbl"> Klima-Schaden</span>
<span class="bb-num bb-neg bb-climate-num">0</span>
</div>
<div class="bb-row bb-net-row">
<span class="bb-lbl">= pro Jahr</span>
<span class="bb-num bb-net">0</span>
</div>
</div>
`
}
return `
<div class="resource" data-tip="${r.id}" data-res-id="${r.id}">
<div class="resource-icon">${r.icon}</div>
<div class="resource-info">
<div class="resource-name">${r.name}</div>
<div class="resource-val"></div>
${extra}
</div>
</div>
`}).join('')
}
// Werte schreiben (aus displayedValues, nicht aus r.current)
for (const r of resources) {
if (this.displayedValues[r.id] === undefined) {
this.displayedValues[r.id] = r.current
}
const row = el.querySelector<HTMLElement>(`[data-res-id="${r.id}"] .resource-val`)
if (row) {
const v = this.displayedValues[r.id]
row.textContent = r.format ? r.format(v) : String(Math.round(v))
}
}
this.updatePowerGrid()
this.updateBudgetBalance()
// Animations-Loop starten (einmal)
this.ensureDisplayAnim()
}
/**
* Aktualisiert die Jahres-Bilanz unter dem Budget-Wert (sim-05-spezifisch).
* Liest game.getYearlyBalance() — wenn die Methode nicht existiert, NoOp.
*/
private updateBudgetBalance(): void {
const host = document.querySelector<HTMLElement>('[data-res-id="budget"] .budget-balance')
if (!host) return
const fn = (this.game as any).getYearlyBalance as (() => { income: number; tourism: number; upkeep: number; climate: number; net: number }) | undefined
if (typeof fn !== 'function') return
const b = fn.call(this.game)
const sel = (q: string) => host.querySelector<HTMLElement>(q)
const incomeEl = sel('.bb-income')
if (incomeEl) incomeEl.textContent = `${b.income} Mio €`
const tourismRow = sel('.bb-tourism') as HTMLElement | null
const tourismEl = sel('.bb-tourism-num')
if (tourismRow) tourismRow.style.display = b.tourism > 0 ? '' : 'none'
if (tourismEl) tourismEl.textContent = `${b.tourism} Mio €`
const upkeepEl = sel('.bb-upkeep')
if (upkeepEl) upkeepEl.textContent = `${b.upkeep} Mio €`
const climateRow = sel('.bb-climate') as HTMLElement | null
const climateEl = sel('.bb-climate-num')
if (climateRow) climateRow.style.display = b.climate > 0 ? '' : 'none'
if (climateEl) climateEl.textContent = `${b.climate} Mio €`
const netEl = sel('.bb-net')
if (netEl) {
const sign = b.net >= 0 ? '+' : ''
netEl.textContent = `${sign}${b.net} Mio €`
netEl.classList.toggle('bb-pos', b.net >= 0)
netEl.classList.toggle('bb-neg', b.net < 0)
}
}
/**
* Aktualisiert die Strom-Netz-Visualisierung in der Status-Box.
* Liest die displayed-power-Kapazität und die aktuelle Bevölkerung,
* berechnet Bedarf = pop/1000 und stellt zwei Balken auf gemeinsame Skala.
*/
private updatePowerGrid(): void {
const host = document.querySelector<HTMLElement>('[data-res-id="power"] .power-grid')
if (!host) return
const cap = Math.round(this.displayedValues['power'] ?? 0)
const pop = this.displayedValues['population'] ?? this.game.getResource('population')
const dem = Math.max(0, Math.round(pop / 1000))
// Skala: höchster Wert + 2 Puffer, mindestens 8
const scale = Math.max(8, Math.max(cap, dem) + 2)
const capPct = Math.min(100, (cap / scale) * 100)
const demPct = Math.min(100, (dem / scale) * 100)
const capFill = host.querySelector<HTMLElement>('.pg-cap')
const demFill = host.querySelector<HTMLElement>('.pg-dem')
const capNum = host.querySelector<HTMLElement>('.pg-cap-num')
const demNum = host.querySelector<HTMLElement>('.pg-dem-num')
const status = host.querySelector<HTMLElement>('.pg-status')
if (capFill) capFill.style.width = `${capPct}%`
if (demFill) demFill.style.width = `${demPct}%`
if (capNum) capNum.textContent = `${cap} MW`
if (demNum) demNum.textContent = `${dem} MW`
const ok = cap >= dem
if (capFill) capFill.classList.toggle('pg-cap-low', !ok)
if (status) {
status.textContent = ok ? 'Versorgung gesichert' : 'Stromausfall — zu wenig!'
status.classList.toggle('pg-status-ok', ok)
status.classList.toggle('pg-status-bad', !ok)
}
}
/**
* Hält einen rAF-Loop am Laufen, der displayedValues langsam zu den echten
* Resource-Werten zieht. Pro Sekunde werden ~30% der Differenz überbrückt,
* bei sehr kleinen Änderungen wird direkt gesetzt.
*/
private ensureDisplayAnim(): void {
if (this.displayAnimRaf !== 0) return
const loop = () => {
let anyMoving = false
for (const r of this.game.getResourcesArray()) {
const target = r.current
const cur = this.displayedValues[r.id]
if (cur === undefined) {
this.displayedValues[r.id] = target
continue
}
const delta = target - cur
const absDelta = Math.abs(delta)
if (absDelta < 0.5 && absDelta < Math.abs(target) * 0.001 + 0.5) {
// Snap wenn nah genug
if (cur !== target) {
this.displayedValues[r.id] = target
anyMoving = true
}
continue
}
// Gedämpftes Hinzählen: 12% der Distanz pro Frame (~60fps)
this.displayedValues[r.id] = cur + delta * 0.12
anyMoving = true
}
if (anyMoving) {
// Kurz UI updaten (nur die Werte-Zellen, nicht das ganze DOM)
const el = document.getElementById('resources')
if (el) {
for (const r of this.game.getResourcesArray()) {
const row = el.querySelector<HTMLElement>(`[data-res-id="${r.id}"] .resource-val`)
if (row) {
const v = this.displayedValues[r.id]
row.textContent = r.format ? r.format(v) : String(Math.round(v))
}
}
// Strom-Netz-Visualisierung und Bilanz mitziehen
this.updatePowerGrid()
this.updateBudgetBalance()
}
}
this.displayAnimRaf = requestAnimationFrame(loop)
}
this.displayAnimRaf = requestAnimationFrame(loop)
}
private renderGoals(): void {
// Ziele werden entweder in ein separates #goals-Element gerendert,
// oder — falls kein separates Element existiert — in das gemeinsame
// #status-goals Element (dann unter den Resources in der Status-Box).
const goalsEl = document.getElementById('goals') || document.getElementById('status-goals')
if (!goalsEl) return
const snap = this.game.getSnapshot()
const goalsList = (this.game as any).goals as Array<{ title: string; description: string }>
goalsEl.innerHTML = snap.goals.map((g, i) => {
const goal = goalsList?.[i] || { title: g.id, description: '' }
return `
<div class="goal">
<div class="goal-row">
<div class="goal-check ${g.achieved ? 'done' : ''}">${g.achieved ? '✓' : ''}</div>
<div>${goal.title || g.id}</div>
</div>
<div class="goal-bar"><div class="goal-bar-fill" style="width:${g.progress}%"></div></div>
</div>
`
}).join('')
}
private renderShop(): void {
const el = document.getElementById('measures')
if (!el) return
const budget = this.game.getResource('budget')
const getCount = this.cfg.getOwnedCount
const canDemolish = !!this.cfg.onDemolish
el.innerHTML = this.cfg.shopItems.map(item => {
const canAfford = budget >= item.cost
const owned = getCount ? getCount(item.id) : 0
const badgesHtml = (item.badges || []).map(b => `<span class="measure-stat measure-${b.type}">${b.label}</span>`).join('')
const ownedBadge = owned > 0
? `<span class="measure-owned">×${owned}</span>`
: ''
const demolishBtn = (owned > 0 && canDemolish)
? `<button class="measure-demolish" data-demolish="${item.id}" title="Abreißen (50 % zurück)">🗑</button>`
: ''
return `
<div class="measure-card ${canAfford ? '' : 'disabled'}" data-id="${item.id}">
<div class="measure-icon">${item.emoji}</div>
<div class="measure-info">
<div class="measure-name">${item.name} ${ownedBadge}</div>
<div class="measure-desc">${item.description}</div>
<div class="measure-stats">
<span class="measure-stat measure-cost">💰 ${item.cost} Mio €</span>
${badgesHtml}
${item.upkeep ? `<span class="measure-stat">⚙ ${item.upkeep} Mio €/J</span>` : ''}
</div>
</div>
${demolishBtn}
</div>
`
}).join('')
el.querySelectorAll<HTMLDivElement>('.measure-card').forEach(card => {
card.addEventListener('click', (ev) => {
// Klick auf den Abreißen-Button NICHT als Kauf werten
const target = ev.target as HTMLElement
if (target.closest('.measure-demolish')) return
if (card.classList.contains('disabled')) return
const id = card.dataset.id!
// Wenn der Baueditor-Hook gesetzt ist, statt sofort zu kaufen den
// Placement-Mode starten. Sonst direkt kaufen (Auto-Platzierung).
if (this.cfg.onStartPlacement) {
this.cfg.onStartPlacement(id)
} else {
this.cfg.onBuy(id)
}
})
})
el.querySelectorAll<HTMLButtonElement>('.measure-demolish').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
const id = btn.getAttribute('data-demolish')
if (id && this.cfg.onDemolish) this.cfg.onDemolish(id)
})
})
}
private renderEvents(): void {
const el = document.getElementById('events')
if (!el) return
const events = this.game.getEvents(20)
if (events.length === 0) {
el.innerHTML = '<div style="font-size:.65rem;color:var(--text3);text-align:center;padding:.4rem">Noch keine Ereignisse</div>'
return
}
const yearOffset = this.cfg.yearOffset || 0
el.innerHTML = events.map(e => {
const prefix = yearOffset > 0 ? yearOffset + e.tick : 'Jahr ' + e.tick
const infoBtn = e.infoKey && INFO_TOPICS[e.infoKey]
? `<button class="event-info-btn" data-event-info="${e.infoKey}" title="Mehr erfahren">i</button>`
: ''
return `
<div class="event event-${e.severity}">
<div class="event-text"><strong>${prefix}:</strong> ${e.text}</div>
${infoBtn}
</div>
`
}).join('')
el.querySelectorAll<HTMLButtonElement>('.event-info-btn').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-event-info')
if (key && INFO_TOPICS[key]) openInfoOverlay(INFO_TOPICS[key])
})
})
}
private renderGraphs(): void {
const snap = this.game.getSnapshot()
const tl = snap.timeline
const currentTick = snap.tick
for (const g of this.cfg.graphs) {
const points: Array<{ x: number; y: number }> = tl.map(e => ({
x: e.tick,
y: e.values[g.field] ?? 0,
}))
// Aktuellen Live-Wert anhängen, damit Käufe sofort sichtbar sind —
// auch wenn noch kein Tick darüber gelaufen ist.
const liveValue = this.game.getResource(g.field)
const lastPt = points[points.length - 1]
if (!lastPt || lastPt.x < currentTick || lastPt.y !== liveValue) {
points.push({ x: currentTick, y: liveValue })
}
this.drawGraph(g, points)
}
}
private drawGraph(cfg: GraphConfig, points: { x: number; y: number }[]): void {
const svg = document.getElementById(cfg.id) as unknown as SVGElement | null
if (!svg) return
const w = (svg as any).clientWidth || 280
const h = (svg as any).clientHeight || 110
svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
const maxTicks = (this.game as any).meta?.maxTicks || 75
// Platz links für die Y-Achse
const axisW = 36
const plotX = axisW
const plotW = w - axisW - 4
const plotY0 = 4
const plotY1 = h - 4
const plotH = plotY1 - plotY0
// === Auto-Skalierung ===
let yMin = cfg.yMin
let yMax = cfg.yMax
if (cfg.autoScale && points.length > 0) {
let maxV = -Infinity, minV = Infinity
for (const p of points) {
if (p.y > maxV) maxV = p.y
if (p.y < minV) minV = p.y
}
// yMax dynamisch: mindestens Config-Wert, erweitert wenn Daten darüber
if (maxV > yMax) {
yMax = this.niceCeiling(maxV * 1.10)
}
// yMin darf nach unten gehen, wenn Daten darunter
if (minV < yMin) {
yMin = this.niceFloor(minV * 1.10)
}
}
const yRange = yMax - yMin || 1
// Helper: y-Wert → Pixel im Plot-Bereich
const yToPx = (y: number) =>
plotY0 + plotH - ((y - yMin) / yRange) * plotH
// === Hintergrund-Zonen (werden nur im Plot-Bereich gezeichnet) ===
let bgRects = ''
if (cfg.zones) {
for (const z of cfg.zones) {
const y1 = Math.max(plotY0, yToPx(z.to))
const y2 = Math.min(plotY1, yToPx(z.from))
const zoneH = Math.max(0, y2 - y1)
if (zoneH <= 0) continue
bgRects += `<rect x="${plotX}" y="${y1.toFixed(1)}" width="${plotW}" height="${zoneH.toFixed(1)}" fill="${z.color}" />`
if (z.label) {
bgRects += `<text x="${plotX + 4}" y="${(y1 + 10).toFixed(1)}" font-size="9" fill="rgba(40,40,40,.55)" font-weight="600">${this.escapeXml(z.label)}</text>`
}
}
}
// === Y-Achse mit Tick-Labels (links) ===
let yAxis = ''
// 4 Ticks: yMin, 1/3, 2/3, yMax
const ticks = [yMin, yMin + yRange / 3, yMin + (yRange * 2) / 3, yMax]
for (const t of ticks) {
const y = yToPx(t)
// Tick-Text
const labelText = this.formatTick(t, cfg)
yAxis += `<text x="${(axisW - 4).toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="rgba(40,40,40,.55)" font-weight="600">${this.escapeXml(labelText)}</text>`
// Dezenter Gitterstrich
yAxis += `<line x1="${plotX}" y1="${y.toFixed(1)}" x2="${plotX + plotW}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,0.06)" stroke-width="0.8" />`
}
// === Referenzlinien ===
let refLines = ''
if (cfg.lines) {
for (const l of cfg.lines) {
const y = yToPx(l.at)
if (y < plotY0 || y > plotY1) continue
const dash = l.dashed ? 'stroke-dasharray="3 3"' : ''
refLines += `<line x1="${plotX}" y1="${y.toFixed(1)}" x2="${plotX + plotW}" y2="${y.toFixed(1)}" stroke="${l.color}" stroke-width="1.3" ${dash} />`
if (l.label) {
refLines += `<text x="${(plotX + plotW - 4).toFixed(1)}" y="${(y - 2).toFixed(1)}" text-anchor="end" font-size="9" font-weight="700" fill="${l.color}">${this.escapeXml(l.label)}</text>`
}
}
}
// === Linie + Punkt + aktueller Wert ===
let linePath = ''
let valueLabel = ''
if (points.length >= 1) {
const path = points.map((p, i) => {
const x = plotX + (p.x / maxTicks) * plotW
const y = Math.max(plotY0 + 2, Math.min(plotY1 - 2, yToPx(p.y)))
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`
}).join(' ')
if (points.length >= 2) {
linePath = `<path d="${path}" fill="none" stroke="${cfg.color}" stroke-width="2.3" stroke-linejoin="round" stroke-linecap="round" />`
}
const lastPt = points[points.length - 1]
const xLast = plotX + (lastPt.x / maxTicks) * plotW
const yLast = Math.max(plotY0 + 6, Math.min(plotY1 - 6, yToPx(lastPt.y)))
// Pulsierender Halo (SMIL-Animation, läuft browserseitig völlig autonom)
linePath += `
<circle cx="${xLast.toFixed(1)}" cy="${yLast.toFixed(1)}" r="5" fill="${cfg.color}" opacity="0.45">
<animate attributeName="r" values="4;10;4" dur="1.8s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.55;0.05;0.55" dur="1.8s" repeatCount="indefinite" />
</circle>
`
linePath += `<circle cx="${xLast.toFixed(1)}" cy="${yLast.toFixed(1)}" r="3.5" fill="${cfg.color}" stroke="#fff" stroke-width="1.2" />`
// Live-Wert direkt aus den Resources holen, damit auch Format mit Einheit funktioniert
const liveResource = this.game.getResourcesArray().find(r => r.id === cfg.field)
let valText: string
if (liveResource?.format) {
valText = liveResource.format(lastPt.y)
} else if (cfg.format) {
valText = cfg.format(lastPt.y)
} else {
const num = lastPt.y.toFixed(Math.abs(lastPt.y) > 50 ? 0 : 1)
valText = cfg.unit ? `${num} ${cfg.unit}` : num
}
const textW = Math.max(40, Math.min(108, valText.length * 6.2 + 14))
const badgeX = plotX + plotW - textW
valueLabel = `
<rect x="${badgeX.toFixed(1)}" y="4" width="${textW}" height="16" rx="4" fill="${cfg.color}" />
<text x="${(plotX + plotW - 6).toFixed(1)}" y="15" text-anchor="end" font-size="10" font-weight="800" fill="#fff">${this.escapeXml(valText)}</text>
`
}
svg.innerHTML = `
${bgRects}
${yAxis}
${refLines}
${linePath}
${valueLabel}
`
}
/** Kurz-Format für Y-Achsen-Ticks */
private formatTick(v: number, cfg: GraphConfig): string {
// Sehr kompakt halten — Platz ist limitiert
if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1).replace('.0', '') + 'k'
if (Math.abs(v) >= 100) return Math.round(v).toString()
if (Number.isInteger(v)) return v.toString()
return v.toFixed(1)
}
/** Rundet nach oben auf schöne Werte: 10, 20, 50, 100, 200, 500, 1000, ... */
private niceCeiling(v: number): number {
if (v <= 0) return 10
const exp = Math.floor(Math.log10(v))
const base = Math.pow(10, exp)
const n = v / base
let nice: number
if (n <= 1) nice = 1
else if (n <= 2) nice = 2
else if (n <= 5) nice = 5
else nice = 10
return nice * base
}
private niceFloor(v: number): number {
if (v >= 0) return 0
return -this.niceCeiling(-v)
}
private escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
private renderTutorial(): void {
const el = document.getElementById('tutorial')
if (!el) return
const step = this.game.getCurrentTutorialStep()
const snap = this.game.getSnapshot()
if (!step || snap.state !== 'tutorial') {
el.style.display = 'none'
if (!this.renderer) {
const wrap = document.getElementById('canvas-wrap')!
this.renderer = new this.cfg.Renderer(wrap, this.game)
this.renderer.start()
}
return
}
el.style.display = 'flex'
document.getElementById('tut-title')!.textContent = step.title
document.getElementById('tut-text')!.textContent = step.text
const totalSteps = (this.game as any).meta?.tutorialSteps || 4
const dotsEl = document.getElementById('tut-dots')!
dotsEl.innerHTML = ''
for (let i = 0; i < totalSteps; i++) {
const d = document.createElement('div')
d.className = 'dot' + (i === snap.tutorialStep ? ' active' : '')
dotsEl.appendChild(d)
}
const nextBtn = document.getElementById('tut-next')!
nextBtn.textContent = snap.tutorialStep === totalSteps - 1 ? 'Simulation starten 🚀' : 'Weiter →'
}
private showEndScreen(): void {
if (this.endShown) return
this.endShown = true
const snap = this.game.getSnapshot()
const meta = (this.game as any).meta
const maxTicks = meta?.maxTicks || 0
const tickUnit = meta?.tickUnit || 'Ticks'
const won = snap.state === 'won'
const lost = snap.state === 'lost'
const finishedEarly = !won && !lost && maxTicks > 0 && snap.tick < maxTicks
// Verpflichtende Ziele auswerten
const goalsList = (this.game as any).goals as Array<{ title: string; required?: boolean }>
const requiredGoals = (goalsList || []).map((g, i) => ({
title: g.title,
required: g.required !== false,
achieved: snap.goals[i]?.achieved ?? false,
})).filter(g => g.required)
const requiredAchieved = requiredGoals.filter(g => g.achieved).length
const requiredTotal = requiredGoals.length
// Icon + Title sind ehrlich
let icon: string
let title: string
let summary: string
if (won) {
icon = '🏆'
title = 'Mission erfüllt!'
summary = `Du hast bis ${this.cfg.yearOffset ? this.cfg.yearOffset + snap.tick : snap.tick} alle ${requiredTotal} Hauptziele erreicht. Glückwunsch!`
} else if (lost) {
icon = '💔'
title = 'Simulation gescheitert'
summary = `Im Jahr ${this.cfg.yearOffset ? this.cfg.yearOffset + snap.tick : snap.tick} ist deine Mission gescheitert. ${requiredAchieved} von ${requiredTotal} Zielen waren erreicht.`
} else if (finishedEarly) {
icon = '⏹'
title = 'Vorzeitig beendet'
const remaining = maxTicks - snap.tick
summary = `Du hast nach ${snap.tick} von ${maxTicks} ${tickUnit} abgebrochen — noch ${remaining} ${tickUnit} wären übrig gewesen. ${requiredAchieved} von ${requiredTotal} Zielen waren zu diesem Zeitpunkt erreicht. <strong>Das ist kein Sieg.</strong>`
} else {
icon = '🏁'
title = 'Simulationsdauer zu Ende'
summary = `Du hast ${snap.tick} ${tickUnit} überstanden, aber nicht alle Hauptziele erreicht (${requiredAchieved}/${requiredTotal}).`
}
// Goal-Liste mit ✓ / ✗
const goalsHtml = requiredGoals.map(g => `
<div class="end-goal ${g.achieved ? 'done' : 'fail'}">
<span class="end-goal-icon">${g.achieved ? '✓' : '✗'}</span>
<span>${g.title}</span>
</div>
`).join('')
// Resourcen
const statsHtml = this.game.getResourcesArray().slice(0, 4).map(r => `
<div class="end-stat">
<div class="end-stat-val">${r.format ? r.format(r.current) : r.current}</div>
<div class="end-stat-lbl">${r.name}</div>
</div>
`).join('')
const html = `
<div class="end-screen">
<div class="end-card">
<div class="end-icon">${icon}</div>
<h2>${title}</h2>
<p class="end-summary">${summary}</p>
<div class="end-goals">${goalsHtml}</div>
<div class="end-stats">${statsHtml}</div>
<button class="tut-btn" id="end-replay">🔄 Nochmal spielen</button>
</div>
</div>
`
document.getElementById('end-screen-host')!.innerHTML = html
document.getElementById('end-replay')?.addEventListener('click', () => {
this.resetting = true
localStorage.removeItem(this.cfg.saveKey)
location.reload()
})
}
private toast(msg: string): void {
// Simple alert for now
alert(msg)
}
}
+304
View File
@@ -0,0 +1,304 @@
/**
* Info-Overlay — Erklärende Begriffs-Popups für Kinder (1014 Jahre).
*
* Nutzung:
* import { INFO_TOPICS, openInfoOverlay } from '@ui/info-overlay'
* openInfoOverlay(INFO_TOPICS.co2)
*
* Zeigt ein einfaches, kinderverständliches Erklär-Panel über der Simulation.
* Braucht im HTML nur einen leeren <div id="info-overlay-host"></div>.
* Die passenden CSS-Klassen (.info-overlay, .info-card, .info-hint) werden
* in der jeweiligen Seite definiert.
*/
export interface InfoTopic {
title: string
/** Lange Erklärung in einfachen Worten. Darf \n\n für Absätze enthalten. */
text: string
/** Optionaler "Merksatz" am Ende */
hint?: string
}
/**
* Kurzdefinition für Mouseover-Tooltips.
* 12 Sätze, die eine Einheit oder ein Fachwort knapp erklären.
*/
export interface TipDef {
title: string
text: string
}
export const TIPS: Record<string, TipDef> = {
ppm: {
title: 'ppm',
text: '„parts per million" wie viele CO₂-Teilchen unter 1 Million Luft-Teilchen sind. 425 ppm = 425 CO₂-Teilchen unter einer Million. Früher waren es 280, ab 500 wird es gefährlich.',
},
celsius: {
title: '°C',
text: 'Grad Celsius. Die globale Durchschnitts­temperatur der Erde. Vorindustriell: 15 °C. Pariser Klimaziel: unter 17 °C halten.',
},
paris_goal: {
title: 'Pariser Klimaziel',
text: '2015 haben fast alle Länder der Welt in Paris versprochen, die Erderwärmung auf höchstens 2 °C über dem vorindustriellen Niveau zu begrenzen — besser sogar auf 1,5 °C. In unserer Simulation heißt das: Die globale Temperatur darf nicht über 17 °C steigen.',
},
cm: {
title: 'cm Meeresspiegel',
text: 'Zentimeter, um die das Meer seit Start angestiegen ist. 30 cm = Strand weg. 60 cm = erste Häuser betroffen.',
},
mio_euro: {
title: 'Mio €',
text: 'Millionen Euro. So viel Geld hat der ganze Inselstaat zur Verfügung. 1 Mio € = 1.000.000 €.',
},
population: {
title: 'Bevölkerung',
text: 'Wie viele Menschen auf der Insel leben. Mehr Leute = mehr Steuern, aber auch mehr zu schützen.',
},
budget: {
title: 'Budget',
text: 'Dein Geld. Steigt durch Steuern (Bevölkerung), sinkt durch Baukosten und Wartung. Unter 0 = Pleite.',
},
flooded: {
title: 'Überflutete Gebiete',
text: 'Wie viel Prozent deiner Stadt schon unter Wasser stehen. Ab 30% drohst du zu verlieren.',
},
upkeep: {
title: 'Wartung',
text: 'Jedes Jahr abgezogene Kosten, um eine Maßnahme (Solar, Wind, Deich …) am Laufen zu halten.',
},
power: {
title: 'Strom (MW)',
text: 'Megawatt die Einheit für elektrische Leistung. Eine Insel mit 6.000 Einwohnern braucht etwa 6 MW. Die Anzeige „6/8 MW" heißt: 6 MW werden gebraucht, 8 MW sind da. Wenn der Bedarf größer wird als die Kapazität, hast du einen Stromausfall.',
},
blackout: {
title: 'Stromausfall',
text: 'Wenn deine Kraftwerke nicht genug Strom liefern, wird es kalt. Die Leute fällen Bäume und verheizen das Holz, um warm zu bleiben. Das setzt zusätzliches CO₂ frei — und tote Bäume können kein CO₂ mehr binden. Ein doppelter Schaden! Baue rechtzeitig genug Kraftwerke (Wind oder Solar — Kohle ist billiger, aber schlecht für das Klima).',
},
}
let tipEl: HTMLDivElement | null = null
let tipInstalled = false
function ensureTipElement(): HTMLDivElement {
if (tipEl) return tipEl
tipEl = document.createElement('div')
tipEl.className = 'info-tip'
tipEl.style.cssText = `
position: fixed; pointer-events: none; z-index: 95;
max-width: 260px; padding: .55rem .75rem;
background: #1a2b33; color: #f5f6f4;
border-radius: 8px; font-size: .78rem; line-height: 1.45;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
opacity: 0; transition: opacity .12s ease-out;
font-family: inherit;
`
document.body.appendChild(tipEl)
return tipEl
}
function showTip(key: string, x: number, y: number): void {
const tip = TIPS[key]
if (!tip) return
const el = ensureTipElement()
el.innerHTML = `
<div style="font-weight:800; color:#8ecbd8; margin-bottom:.15rem; font-size:.72rem; letter-spacing:.03em; text-transform:uppercase;">${escapeHtml(tip.title)}</div>
<div>${escapeHtml(tip.text)}</div>
`
// Positionierung: rechts unterhalb des Cursors, außer wenn am Rand
const pad = 12
const rect = { w: 280, h: 80 } // grobe Schätzung
let left = x + pad
let top = y + pad
if (left + rect.w > window.innerWidth) left = x - rect.w - pad
if (top + rect.h > window.innerHeight) top = y - rect.h - pad
el.style.left = `${Math.max(6, left)}px`
el.style.top = `${Math.max(6, top)}px`
el.style.opacity = '1'
}
function hideTip(): void {
if (tipEl) tipEl.style.opacity = '0'
}
/**
* Installiert einen globalen Delegated-Listener für `data-tip="key"`-Elemente.
* Muss nur einmal pro Seite aufgerufen werden.
*/
export function installTooltips(): void {
if (tipInstalled) return
tipInstalled = true
document.addEventListener('mouseover', (e) => {
const target = (e.target as HTMLElement)?.closest('[data-tip]') as HTMLElement | null
if (!target) return
const key = target.getAttribute('data-tip')
if (!key) return
showTip(key, e.clientX, e.clientY)
})
document.addEventListener('mousemove', (e) => {
if (!tipEl || tipEl.style.opacity === '0') return
const target = (e.target as HTMLElement)?.closest('[data-tip]') as HTMLElement | null
if (!target) { hideTip(); return }
const key = target.getAttribute('data-tip')
if (!key || !TIPS[key]) { hideTip(); return }
// Tooltip mitbewegen
const pad = 12
let left = e.clientX + pad
let top = e.clientY + pad
if (left + 280 > window.innerWidth) left = e.clientX - 290
if (top + 80 > window.innerHeight) top = e.clientY - 90
tipEl.style.left = `${Math.max(6, left)}px`
tipEl.style.top = `${Math.max(6, top)}px`
})
document.addEventListener('mouseout', (e) => {
const target = (e.target as HTMLElement)?.closest('[data-tip]')
if (!target) return
hideTip()
})
}
export const INFO_TOPICS: Record<string, InfoTopic> = {
co2: {
title: '🌫 CO₂ — das Treibhaus-Gas',
text:
'CO₂ (Kohlenstoffdioxid) ist ein unsichtbares Gas in der Luft. Du atmest es auch aus. Pflanzen brauchen es zum Wachsen.\n\n' +
'Das Problem: Wenn Menschen Benzin, Kohle oder Gas verbrennen, entsteht sehr viel CO₂. Das bleibt über hunderte Jahre in der Luft und wirkt wie ein dickes Dach über der Erde — die Wärme der Sonne kommt rein, aber nur schwer wieder raus.\n\n' +
'Vor 200 Jahren hatte unsere Luft etwa 280 ppm CO₂. Heute sind es über 420 ppm. Je mehr CO₂, desto wärmer wird es.',
hint: 'ppm bedeutet: wieviele CO₂-Teilchen in einer Million Luft-Teilchen drinstecken.',
},
temperature: {
title: '🌡 Globale Temperatur',
text:
'Die Erde hat eine Durchschnittstemperatur. Das ist die mittlere Temperatur von allen Orten und allen Jahreszeiten zusammen.\n\n' +
'Vor der Industrialisierung (etwa 1850) lag sie bei rund 15°C. Heute ist sie schon um mehr als 1°C gestiegen.\n\n' +
'Das klingt wenig — aber 2°C mehr würden bedeuten: Gletscher schmelzen, Meere steigen, Hitzewellen werden häufiger, viele Tiere verlieren ihr Zuhause.',
hint: 'Im Pariser Klimaabkommen (2015) haben sich die Länder geeinigt, die Erwärmung auf unter 2°C zu halten — möglichst sogar unter 1,5°C.',
},
sealevel: {
title: '🌊 Meeresspiegelanstieg',
text:
'Wenn es wärmer wird, passieren zwei Dinge mit den Meeren:\n\n' +
'1) Das Wasser dehnt sich aus. Wie ein Metallstab, der im Feuer länger wird.\n' +
'2) Die Gletscher und das Eis am Nord- und Südpol schmelzen. Das viele Schmelzwasser fließt ins Meer.\n\n' +
'Beides zusammen lässt den Meeresspiegel langsam, aber unaufhaltsam steigen. Schon 50 cm mehr reichen, um Strände, Felder und ganze Küstenstädte unter Wasser zu setzen.\n\n' +
'Inselstaaten wie Tuvalu oder die Malediven sind besonders gefährdet. Dort leben Menschen, die ihr Zuhause verlieren könnten.',
hint: 'Wenn das gesamte Eis der Antarktis schmelzen würde, stiege das Meer um etwa 60 Meter.',
},
budget: {
title: '💰 Budget',
text:
'Dein Budget ist das Geld, das der ganzen Insel zur Verfügung steht — angegeben in Millionen Euro (Mio €). Jedes Jahr bekommst du Steuern von der Bevölkerung. Je mehr Menschen auf der Insel leben, desto mehr kannst du einnehmen.\n\n' +
'Aber: Alles was du gebaut hast, kostet auch jedes Jahr Wartung. Ein Windpark kostet z. B. 18 Mio € pro Jahr, damit er gewartet wird und funktioniert.\n\n' +
'Wenn dein Budget unter 0 Mio € fällt, bist du pleite — dann hast du verloren.',
hint: 'Überlege vor jedem Bau: Kann ich mir auch die Wartung in den nächsten Jahren leisten?',
},
glacier: {
title: '🏔 Gletscher',
text:
'Gletscher sind riesige, uralte Eismassen. Sie entstehen in den Bergen, wo über Jahrtausende Schnee fällt und sich zu Eis verdichtet.\n\n' +
'Gletscher sind wichtig: Im Sommer schmilzt etwas Eis und liefert Trinkwasser für Menschen, Tiere und Felder weiter unten im Tal.\n\n' +
'Wenn es zu warm wird, schmelzen die Gletscher schneller als neuer Schnee nachkommt. Am Ende sind sie ganz verschwunden — und das Trinkwasser im Sommer fehlt.',
hint: 'Der Vulkan auf unserer Insel hat einen Gletscher als Kappe. Wenn die Temperatur steigt, kannst du zusehen, wie er schrumpft.',
},
wedge: {
title: '⛰ Warum ist die Insel schräg?',
text:
'Echte Inseln sind fast nie flach. Sie haben meistens eine höhere Seite (zum Beispiel mit einem Berg) und eine niedrigere, die sanft ins Meer abfällt.\n\n' +
'Das Tiefland ist besonders in Gefahr, wenn der Meeresspiegel steigt. Genau das passiert auch auf unserer Insel: Die Vorderseite wird zuerst nass, weil sie fast auf Meereshöhe liegt.\n\n' +
'Das ist kein Programmfehler — das soll zeigen, dass man mit Deichen zwar Häuser schützen kann, aber nicht die ganze Landschaft.',
},
deich: {
title: '🌊 Deich',
text:
'Ein Deich ist ein aufgeschütteter Wall aus Erde, Sand und Gras, der das Meer von einer Stadt oder einem Feld fernhält.\n\n' +
'In den Niederlanden werden Deiche seit über 1000 Jahren gebaut. Ohne sie wäre ein großer Teil des Landes Meer.\n\n' +
'Ein Deich im Spiel schützt deine Häuser davor, dass sie vom steigenden Meer überflutet werden — aber er hilft nicht gegen die Klimaerwärmung selbst. Du musst trotzdem das CO₂ reduzieren.',
hint: 'Deiche sind eine "Anpassung" — man bekämpft nicht die Ursache, sondern die Folge.',
},
solar: {
title: '☀️ Solaranlage',
text:
'Eine Solaranlage macht aus Sonnenlicht Strom. Auf ihren dunkelblauen Panels sitzen viele kleine Zellen, die Licht in elektrischen Strom umwandeln.\n\n' +
'Der große Vorteil: Es entsteht kein CO₂. Das ist der wichtigste Unterschied zu einem Kraftwerk, das Kohle oder Gas verbrennt.\n\n' +
'Deshalb ersetzt jede Solaranlage ein Stück "schmutzigen" Strom. In der Simulation siehst du das so: Bei jedem Haus, das eine Solaranlage hat, verschwindet die Rauchwolke vom Schornstein.',
},
wind: {
title: '💨 Windpark',
text:
'Ein Windpark sind mehrere große Windräder. Der Wind dreht die Flügel, und ein Generator im Turm macht daraus Strom.\n\n' +
'Wind ist kostenlos und erzeugt keinen CO₂. Ein einziges großes Windrad kann den Strom für hunderte Haushalte liefern.\n\n' +
'In der Simulation ist ein Windpark teurer als eine Solaranlage, reduziert aber deutlich mehr CO₂. Er ist die stärkste Einzel-Maßnahme.',
},
forest: {
title: '🌲 Wald aufforsten',
text:
'Bäume holen CO₂ aus der Luft und speichern es in ihrem Holz. Ein ausgewachsener Baum bindet pro Jahr etwa 10 kg CO₂.\n\n' +
'Wenn du einen Wald pflanzt, hilfst du also direkt gegen den Klimawandel. Der Effekt ist aber klein — du brauchst sehr viele Bäume, um wirklich etwas zu bewirken.\n\n' +
'Wälder sind günstig, aber allein reichen sie nicht aus. Am besten kombinierst du sie mit Solaranlagen oder Windrädern.',
},
vegetation: {
title: '🌿 Warum sterben Pflanzen bei Meeresanstieg?',
text:
'Die meisten Pflanzen können kein salziges Wasser vertragen. Wenn das Meer steigt, sickert Salzwasser durch den Boden — auch dort, wo du es gar nicht siehst.\n\n' +
'Das Salz zerstört die feinen Wurzeln. Die Pflanzen können kein Wasser mehr aufnehmen und vertrocknen, obwohl der Boden nass ist. Ganze Wälder und Felder werden so kaputt.\n\n' +
'In Kenia, an der Küste, sind in den letzten Jahren schon viele Mangroven und Bauernfelder durch Salzwasser gestorben. Das ist nicht Theorie — das passiert gerade.',
hint: 'Fachbegriff: Versalzung. Erst gehen die Pflanzen kaputt, dann die Böden.',
},
drinking_water: {
title: '💧 Trinkwasser in Gefahr',
text:
'Auf Inseln gibt es meistens keine Flüsse oder Seen. Die Menschen bekommen ihr Trinkwasser aus Brunnen — also aus Süßwasser, das unter der Erde liegt.\n\n' +
'Wenn das Meer steigt, drückt das salzige Meerwasser unter der Insel durch und vermischt sich mit dem Süßwasser. Der Brunnen gibt dann salziges Wasser — und das kann man nicht trinken.\n\n' +
'Viele Inseln haben deshalb heute schon Probleme, obwohl die Häuser noch gar nicht unter Wasser stehen.',
},
}
/**
* Öffnet das Info-Overlay. Braucht einen <div id="info-overlay-host"></div>
* im Seiten-HTML.
*/
export function openInfoOverlay(topic: InfoTopic | null | undefined): void {
const host = document.getElementById('info-overlay-host')
if (!host) return
// Defensive: niemals ein leeres Modal zeigen, wenn topic fehlt oder kein Inhalt da ist.
if (!topic || !topic.title || !topic.text) {
host.innerHTML = ''
return
}
const paragraphs = topic.text.split('\n\n').map(p => `<p>${escapeHtml(p)}</p>`).join('')
const hintHtml = topic.hint
? `<div class="info-hint">💡 ${escapeHtml(topic.hint)}</div>`
: ''
host.innerHTML = `
<div class="info-overlay" id="info-overlay-bg">
<div class="info-card">
<h3>${escapeHtml(topic.title)}</h3>
${paragraphs}
${hintHtml}
<button class="tut-btn" id="info-close">Verstanden ✓</button>
</div>
</div>
`
const close = () => { host.innerHTML = '' }
document.getElementById('info-close')?.addEventListener('click', close)
document.getElementById('info-overlay-bg')?.addEventListener('click', (e) => {
if ((e.target as HTMLElement).id === 'info-overlay-bg') close()
})
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+450
View File
@@ -0,0 +1,450 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Stadt & Raumplanung</title>
<link rel="icon" type="image/png" href="assets/img/logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#f4f3ef;--white:#fff;--text:#1a1a1a;--text2:#4a4a4a;--text3:#8a8a8a;--fjord:#4a7c8a;--fjord-l:#dae8ec;--fjord-d:#3a6470;--moss:#5a8a5e;--sand:#c4a35a;--coral:#c07a6b;--coral-l:#f2ddd8}
html,body{height:100%;overflow:hidden}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column}
.nav{padding:.4rem .8rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(0,0,0,.05);background:var(--white);flex-shrink:0}
.nav-brand{display:flex;align-items:center;gap:.4rem;font-weight:800;font-size:.9rem;color:var(--fjord);text-decoration:none}
.nav-brand img{width:24px;height:24px;border-radius:5px}
.nav-btn{padding:.25rem .5rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:var(--white);color:var(--text2);font-size:.65rem;font-weight:600;cursor:pointer;text-decoration:none}
.main{flex:1;display:grid;grid-template-columns:160px 1fr 180px;gap:.3rem;padding:.3rem;min-height:0;overflow:hidden}
.panel{background:var(--white);border-radius:8px;border:1px solid rgba(0,0,0,.04);padding:.4rem;box-shadow:0 1px 3px rgba(0,0,0,.02);overflow-y:auto}
.panel-h{font-size:.54rem;font-weight:700;color:var(--fjord);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem}
.col-left,.col-right{display:flex;flex-direction:column;gap:.3rem;overflow-y:auto}
.col-center{display:flex;flex-direction:column;gap:.25rem;min-height:0}
.topbar{flex-shrink:0;display:flex;align-items:center;gap:.4rem;background:var(--white);border-radius:7px;padding:.25rem .5rem;border:1px solid rgba(0,0,0,.04)}
.topbar .stat{font-size:.65rem;font-weight:700;display:flex;align-items:center;gap:.15rem}
.topbar .v{font-size:.8rem}
.topbar .gold .v{color:var(--sand)}
.topbar .rnd{font-size:.62rem;font-weight:700;color:var(--fjord);margin-left:auto;background:var(--fjord-l);padding:.1rem .4rem;border-radius:4px}
.btn-next{padding:.25rem .6rem;border:none;border-radius:5px;background:var(--fjord);color:#fff;font-size:.65rem;font-weight:700;cursor:pointer;margin-left:.2rem}
.canvas-wrap{flex:1;background:#92aa6a;border-radius:8px;overflow:hidden;position:relative;min-height:200px;cursor:pointer}
.canvas-wrap canvas{width:100%;height:100%;display:block}
.param{display:flex;align-items:center;gap:.2rem;padding:.15rem 0;border-bottom:1px solid rgba(0,0,0,.02)}.param:last-child{border:none}
.param-i{font-size:.68rem;width:15px;text-align:center}.param-inf{flex:1}
.param-n{font-size:.5rem;color:var(--text3)}.param-bar{height:3px;background:#e8e5dc;border-radius:1.5px;margin-top:1px;overflow:hidden}
.param-bar-f{height:100%;border-radius:1.5px;transition:width .4s}.param-v{font-size:.58rem;font-weight:700;min-width:15px;text-align:right}
.tb{display:flex;align-items:center;gap:.2rem;padding:.22rem .3rem;border:1.5px solid rgba(0,0,0,.04);border-radius:5px;background:var(--white);cursor:pointer;width:100%;text-align:left;margin-bottom:.1rem;transition:all .1s;font-size:.55rem}
.tb:hover{border-color:var(--fjord);background:var(--fjord-l)}.tb.on{border-color:var(--fjord);background:var(--fjord);color:#fff}
.tb.off{opacity:.2;pointer-events:none;filter:grayscale(100%)}
.tb .te{font-size:.75rem;width:16px;text-align:center}.tb .tn{flex:1;font-weight:600}.tb .tc{font-weight:600;color:var(--sand);font-size:.48rem}
.msg{font-size:.52rem;padding:.15rem .25rem;border-radius:4px;margin-bottom:.08rem;background:var(--fjord-l);color:var(--fjord-d);line-height:1.25}
.msg.warn{background:var(--coral-l);color:var(--coral)}.msg.good{background:rgba(90,138,94,.12);color:var(--moss)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:200;backdrop-filter:blur(3px)}
.ov-c{background:var(--white);border-radius:12px;padding:1.2rem;max-width:380px;width:90%;text-align:center}
.ov-c h2{font-size:.95rem;font-weight:800;margin-bottom:.15rem}.ov-c p{font-size:.7rem;color:var(--text2);margin-bottom:.4rem;line-height:1.35}
.ov-btn{padding:.3rem .8rem;border:none;border-radius:5px;background:var(--fjord);color:#fff;font-size:.7rem;font-weight:700;cursor:pointer}
.hidden{display:none!important}
@media(max-width:800px){.main{grid-template-columns:1fr}}
</style>
</head>
<body>
<nav class="nav">
<a href="index.html" class="nav-brand"><img src="assets/img/logo.png" alt="">Stadt & Raumplanung</a>
<div style="display:flex;gap:.15rem"><button id="btn-reset" class="nav-btn">🔄</button><a href="index.html" class="nav-btn"></a></div>
</nav>
<div id="game" class="main">
<div class="col-left">
<div class="panel"><div class="panel-h">Kennwerte</div><div id="params"></div></div>
<div class="panel"><div class="panel-h">Verlauf</div><canvas id="graph" style="width:100%;height:65px;display:block;border-radius:4px;background:#f0eeea"></canvas></div>
</div>
<div class="col-center">
<div class="topbar">
<div class="stat gold">💰 <span class="v" id="h-bud">0</span></div>
<span class="rnd" id="h-rnd">1/10</span>
<button class="btn-next" id="btn-next">▶ Runde</button>
</div>
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
</div>
<div class="col-right">
<div class="panel"><div class="panel-h">Bauen</div><div id="tiles"></div></div>
<div class="panel"><div class="panel-h">Nachrichten</div><div id="msgs"></div></div>
</div>
</div>
<div id="ov-intro" class="ov"><div class="ov-c">
<h2>🏘️ Stadt & Raumplanung</h2>
<p>Baue eine Siedlung auf isometrischen Kacheln. Jede Kachel hat Straßen eingebaut. Achte darauf, dass sie zusammenpassen!</p>
<button class="ov-btn" id="btn-go">Los geht's!</button>
</div></div>
<div id="ov-end" class="ov hidden"><div class="ov-c"><h2 id="end-t"></h2><p id="end-p"></p><button class="ov-btn" id="btn-again">Nochmal</button></div></div>
<script>
// =====================================================================
// STADT & RAUMPLANUNG — Isometrische Kacheln (SimCity-Stil)
// Template-basiertes Kachel-System
// =====================================================================
const GW=10, GH=10
const RW = 0.30 // Strassenbreite relativ zur Kachel
// === TILE TEMPLATE SYSTEM ===
// Jede Kachel wird durch ein Template definiert:
// conn: [N,E,S,W] 1=Strasse verbindet an dieser Kante
// stub: [N,E,S,W] 0-1, wie weit die Zufahrt reinragt (0=keine, 0.1=kurz/Parkplatz, 0.5=halb, 1=voll)
// content: Array von {emoji, ox, oy} relativ zum Kachel-Zentrum (-0.5..0.5)
// eff: Effekte auf Simulation
const TILES = {
empty: {n:'Leer', cost:0, conn:[0,0,0,0], stub:[0,0,0,0], content:[], eff:{}},
// Strassen
road_ns: {n:'Straße N-S', cost:2, conn:[1,0,1,0], stub:[1,0,1,0], content:[], eff:{traf:1,env:-1}},
road_ew: {n:'Straße O-W', cost:2, conn:[0,1,0,1], stub:[0,1,0,1], content:[], eff:{traf:1,env:-1}},
road_cross: {n:'Kreuzung', cost:3, conn:[1,1,1,1], stub:[1,1,1,1], content:[], eff:{traf:2,env:-1}},
road_t_ews: {n:'T offen S', cost:3, conn:[0,1,1,1], stub:[0,1,1,1], content:[], eff:{traf:1,env:-1}},
road_t_new: {n:'T offen W', cost:3, conn:[1,1,0,1], stub:[1,1,0,1], content:[], eff:{traf:1,env:-1}},
road_t_nes: {n:'T offen O', cost:3, conn:[1,1,1,0], stub:[1,1,1,0], content:[], eff:{traf:1,env:-1}},
road_t_nsw: {n:'T offen N', cost:3, conn:[1,0,1,1], stub:[1,0,1,1], content:[], eff:{traf:1,env:-1}},
road_ne: {n:'Kurve N→O', cost:2, conn:[1,1,0,0], stub:[1,1,0,0], content:[], eff:{traf:1,env:-1}},
road_es: {n:'Kurve O→S', cost:2, conn:[0,1,1,0], stub:[0,1,1,0], content:[], eff:{traf:1,env:-1}},
road_sw: {n:'Kurve S→W', cost:2, conn:[0,0,1,1], stub:[0,0,1,1], content:[], eff:{traf:1,env:-1}},
road_wn: {n:'Kurve W→N', cost:2, conn:[1,0,0,1], stub:[1,0,0,1], content:[], eff:{traf:1,env:-1}},
// Gebaeude — Strasse geht durch, Gebaeude am Rand
housing_ns: {n:'Wohngebiet │', cost:6, conn:[1,0,1,0], stub:[1,0,1,0],
content:[{e:'🏠',ox:-0.3,oy:-0.1},{e:'🏡',ox:0.3,oy:0.1},{e:'🏠',ox:0.3,oy:-0.15}], eff:{eco:2,sat:2,env:-1,traf:1}},
housing_ew: {n:'Wohngebiet ─', cost:6, conn:[0,1,0,1], stub:[0,1,0,1],
content:[{e:'🏠',ox:-0.1,oy:-0.3},{e:'🏡',ox:0.1,oy:0.3}], eff:{eco:2,sat:2,env:-1,traf:1}},
shop_s: {n:'Laden ↓', cost:7, conn:[0,0,1,0], stub:[0,0,0.3,0],
content:[{e:'🏪',ox:0,oy:-0.15}], eff:{eco:4,sat:2,env:-1,traf:2,sup:5}},
shop_n: {n:'Laden ↑', cost:7, conn:[1,0,0,0], stub:[0.3,0,0,0],
content:[{e:'🏪',ox:0,oy:0.1}], eff:{eco:4,sat:2,env:-1,traf:2,sup:5}},
park_n: {n:'Park ↑', cost:4, conn:[1,0,0,0], stub:[0.1,0,0,0],
content:[{e:'🌳',ox:-0.15,oy:0.05},{e:'🌲',ox:0.2,oy:-0.1},{e:'🌷',ox:0,oy:0.2,s:0.6}], eff:{sat:4,env:6,sup:1}},
park_s: {n:'Park ↓', cost:4, conn:[0,0,1,0], stub:[0,0,0.1,0],
content:[{e:'🌳',ox:-0.15,oy:-0.1},{e:'🌲',ox:0.2,oy:0.05},{e:'🌷',ox:0,oy:-0.2,s:0.6}], eff:{sat:4,env:6,sup:1}},
school_es: {n:'Schule O↓', cost:9, conn:[0,1,1,0], stub:[0,0.3,0.3,0],
content:[{e:'🏫',ox:-0.15,oy:-0.15}], eff:{eco:1,sat:5,traf:1,sup:4}},
industry_sw: {n:'Industrie S←', cost:8, conn:[0,0,1,1], stub:[0,0,0.5,0.5],
content:[{e:'🏭',ox:0.1,oy:-0.15}], eff:{eco:6,sat:-3,env:-6,traf:4}},
}
function getConn(type){return TILES[type]?.conn||[0,0,0,0]}
// === GAME STATE ===
let grid=[] // GH x GW tile type strings
let level=null,round=0,budget=0,gameOver=false,selType=null
let scores={eco:40,sat:40,env:55,traf:15,sup:15,reach:25}
let messages=[],history=[],ghostR=-1,ghostC=-1
const LEVELS=[{
id:'L1',rounds:10,budget:40,income:6,
unlocked:['road_ns','road_ew','road_cross','road_t_ews','road_t_new','road_t_nes','road_t_nsw','road_ne','road_es','road_sw','road_wn','housing_ns','housing_ew','shop_s','shop_n','park_n','park_s'],
blockBad:false,
initial:[{x:4,y:4,t:'road_ew'},{x:5,y:4,t:'road_ew'},{x:6,y:4,t:'road_ew'},{x:4,y:3,t:'housing_ns'},{x:6,y:5,t:'housing_ns'}],
events:[{r:1,m:'Deine Siedlung braucht einen Laden!'},{r:4,m:'Mehr Wohnraum nötig!'},{r:7,m:'Ein Park erhöht die Zufriedenheit.'},{r:9,m:'Letzte Runde!'}],
win:50
}]
function initGame(lvl){
level=lvl;round=1;budget=lvl.budget;gameOver=false;selType=null;messages=[];history=[]
grid=[];for(let r=0;r<GH;r++){grid[r]=[];for(let c=0;c<GW;c++)grid[r][c]='empty'}
for(const t of lvl.initial)grid[t.y][t.x]=t.t
recalc();const ev=lvl.events.find(e=>e.r===1);if(ev)addMsg(ev.m,'')
}
// === SIM ===
function recalc(){
let eco=30,sat=35,env=55,traf=10,sup=10,reach=20,bad=0,iso=0
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++){
const type=grid[r][c];if(type==='empty')continue
const def=TILES[type],ef=def.eff||{}
eco+=ef.eco||0;sat+=ef.sat||0;env+=ef.env||0;traf+=ef.traf||0;sup+=ef.sup||0
const conn=def.conn
const dirs=[[-1,0,0,2],[0,1,1,3],[1,0,2,0],[0,-1,3,1]]
let anyGood=false
for(const[dr,dc,myD,thD]of dirs){
if(!conn[myD])continue
const nr=r+dr,nc=c+dc
if(nr<0||nr>=GH||nc<0||nc>=GW){bad++;continue}
const nb=grid[nr][nc];if(nb==='empty'){bad++;continue}
if(TILES[nb].conn[thD])anyGood=true;else bad++
}
if(def.content.length>0&&!anyGood){iso++;reach-=4}else if(def.content.length>0)reach+=3
// Neighbors
if(type.startsWith('housing')){const nb=nbT(r,c);if(nb.some(n=>n.startsWith('park')))sat+=2;if(nb.some(n=>n.startsWith('industry'))){sat-=4;env-=3};if(nb.some(n=>n.startsWith('shop')))sup+=2}
}
traf+=bad;reach-=iso*3
const cl=v=>Math.min(100,Math.max(0,Math.round(v)))
scores={eco:cl(eco),sat:cl(sat),env:cl(env),traf:cl(traf),sup:cl(sup),reach:cl(reach)}
}
function nbT(r,c){const res=[];if(r>0)res.push(grid[r-1][c]);if(c<GW-1)res.push(grid[r][c+1]);if(r<GH-1)res.push(grid[r+1][c]);if(c>0)res.push(grid[r][c-1]);return res}
function place(r,c){
if(gameOver||!selType)return
if(grid[r][c]!=='empty'){addMsg('❌ Belegt!','warn');return}
if(budget<TILES[selType].cost){addMsg('❌ Kein Budget!','warn');return}
if(level.blockBad){
const conn=TILES[selType].conn,dirs=[[-1,0,0,2],[0,1,1,3],[1,0,2,0],[0,-1,3,1]]
for(const[dr,dc,myD,thD]of dirs){
if(!conn[myD])continue;const nr=r+dr,nc=c+dc
if(nr>=0&&nr<GH&&nc>=0&&nc<GW&&grid[nr][nc]!=='empty'&&!TILES[grid[nr][nc]].conn[thD]){addMsg('❌ Straße passt nicht!','warn');return}
}
}
grid[r][c]=selType;budget-=TILES[selType].cost;recalc();renderAll()
}
function addMsg(t,type){messages.unshift({text:t,type});if(messages.length>10)messages.pop()}
function nextRound(){
if(gameOver)return;history.push({round,...scores});round++;budget+=level.income
if(round>level.rounds){
gameOver=true;const total=Math.round(0.22*scores.eco+0.24*scores.sat+0.18*scores.env+0.16*scores.sup+0.12*scores.reach+0.08*(100-scores.traf))
document.getElementById('end-t').textContent=total>=level.win?'🎉 Geschafft!':'😔 Nicht bestanden'
document.getElementById('end-p').textContent=`Score: ${total}`;document.getElementById('ov-end').classList.remove('hidden');return
}
const ev=level.events.find(e=>e.r===round);if(ev)addMsg(ev.m,'');renderAll()
}
// === ISOMETRIC RENDERER ===
const $cv=document.getElementById('cv'),ctx=$cv.getContext('2d')
let W=0,H=0,TW=0,TH=0,mapOx=0,mapOy=0,animT=0
// Vehicles array
let cars=[]
function isoX(c,r){return(c-r)*(TW/2)}
function isoY(c,r){return(c+r)*(TH/2)}
function screenToGrid(sx,sy){
const rx=sx-mapOx,ry=sy-mapOy
const c=Math.floor((rx/(TW/2)+ry/(TH/2))/2)
const r=Math.floor((ry/(TH/2)-rx/(TW/2))/2)
return{r,c}
}
function resize(){
const rect=$cv.parentElement.getBoundingClientRect(),dpr=devicePixelRatio||1
$cv.width=rect.width*dpr;$cv.height=rect.height*dpr
ctx.setTransform(dpr,0,0,dpr,0,0)
W=rect.width;H=rect.height
const n=GW+GH
const fitW=(W*0.9)/(n/2),fitH=(H*0.85)/(n/2)
TW=Math.min(fitW,fitH*2);TH=TW/2
mapOx=(W-(GW+GH)*(TW/2)/2)/2+GH*(TW/2)/2+TW/4
mapOy=(H-(GW+GH)*(TH/2)/2)/2+TH
}
function updateCars(){
cars=cars.filter(v=>v.t<1)
if(cars.length<6&&Math.random()<0.03){
const tiles=[]
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++)if(TILES[grid[r][c]]?.conn.some(v=>v))tiles.push({r,c})
if(tiles.length){
const spot=tiles[Math.floor(Math.random()*tiles.length)]
const conn=TILES[grid[spot.r][spot.c]].conn
const dirs=[];conn.forEach((v,i)=>{if(v)dirs.push(i)})
if(dirs.length){
const d=dirs[Math.floor(Math.random()*dirs.length)]
const emojis=['🚗','🚙','🚕','🚌']
cars.push({r:spot.r,c:spot.c,dir:d,t:0,e:emojis[Math.floor(Math.random()*emojis.length)]})
}
}
}
for(const v of cars)v.t+=0.006
}
function draw(){
animT+=0.01
updateCars()
// Sky/grass background
ctx.fillStyle='#92aa6a';ctx.fillRect(0,0,W,H)
// Draw tiles back-to-front
for(let r=0;r<GH;r++)for(let c=0;c<GW;c++){
const type=grid[r][c]
const sx=mapOx+isoX(c,r),sy=mapOy+isoY(c,r)
const isGhost=r===ghostR&&c===ghostC&&type==='empty'&&selType
drawIsoTile(sx,sy,type,1)
if(isGhost){
ctx.globalAlpha=0.4
drawIsoTile(sx,sy,selType,0.4)
ctx.globalAlpha=1
}
}
// Draw cars
const cfs=Math.max(6,TW*0.12)
ctx.font=`${cfs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
for(const v of cars){
const sx=mapOx+isoX(v.c,v.r),sy=mapOy+isoY(v.c,v.r)
// Move along road direction in iso space
const off=v.t*TW*0.4
let dx=0,dy=0
if(v.dir===0){dx=off*0.5;dy=-off*0.25} // N
if(v.dir===1){dx=off*0.5;dy=off*0.25} // E
if(v.dir===2){dx=-off*0.5;dy=off*0.25} // S
if(v.dir===3){dx=-off*0.5;dy=-off*0.25}// W
// Slight offset to right side of road
const rOff=TW*0.04
if(v.dir===0||v.dir===2){dx+=rOff}else{dy+=rOff}
ctx.fillText(v.e,sx+dx,sy+dy)
}
requestAnimationFrame(draw)
}
function drawIsoTile(sx,sy,type,alpha){
const def=TILES[type]
if(!def)return
const hw=TW/2,hh=TH/2
const conn=def.conn
// === DIAMOND BASE (green grass) ===
ctx.fillStyle='#7da84e'
ctx.beginPath()
ctx.moveTo(sx,sy-hh) // top
ctx.lineTo(sx+hw,sy) // right
ctx.lineTo(sx,sy+hh) // bottom
ctx.lineTo(sx-hw,sy) // left
ctx.closePath()
ctx.fill()
// Subtle edge
ctx.strokeStyle='rgba(0,0,0,0.06)';ctx.lineWidth=0.5;ctx.stroke()
// === ROADS as iso parallelograms ===
const rw=TW*RW // road width in pixels
const rhw=rw/2
ctx.fillStyle='#3a3a3a'
// N: from center toward top-left edge
if(conn[0]) drawIsoRoad(sx,sy,hw,hh,rhw,0)
// E: from center toward top-right edge
if(conn[1]) drawIsoRoad(sx,sy,hw,hh,rhw,1)
// S: from center toward bottom-right edge
if(conn[2]) drawIsoRoad(sx,sy,hw,hh,rhw,2)
// W: from center toward bottom-left edge
if(conn[3]) drawIsoRoad(sx,sy,hw,hh,rhw,3)
// Center junction
if(conn.filter(v=>v).length>=2){
ctx.fillStyle='#3a3a3a'
const jr=rhw*0.7
ctx.beginPath();ctx.arc(sx,sy,jr,0,Math.PI*2);ctx.fill()
}
// Road markings (center dashed line + edge lines)
for(let d=0;d<4;d++){
if(!conn[d])continue
drawIsoRoadMarkings(sx,sy,hw,hh,rhw,d)
}
// === BUILDING CONTENT ===
if(def.content.length>0){
const fs=Math.max(8,TW*0.18)
ctx.font=`${fs}px serif`;ctx.textAlign='center';ctx.textBaseline='middle'
for(const item of def.content){
// Convert content position to iso coordinates
const scale=item.s||1
ctx.font=`${fs*scale}px serif`
// ox,oy are in tile-local coords (-0.5..0.5)
// Convert to iso: ox goes along E direction, oy goes along S direction
const px=sx + item.ox*TW*0.45 + item.oy*TW*0.05
const py=sy + item.ox*TH*0.15 + item.oy*TH*0.4
ctx.fillText(item.e,px,py)
}
ctx.textBaseline='alphabetic'
}
}
function drawIsoRoad(sx,sy,hw,hh,rhw,dir){
// Draw road segment from center to edge in direction dir
// In iso: N=top-left, E=top-right, S=bottom-right, W=bottom-left
const steps=6
ctx.beginPath()
for(let i=0;i<=steps;i++){
const t=i/steps*0.5 // 0..0.5 (center to edge)
let px,py
if(dir===0){px=sx-t*hw;py=sy-t*hh} // N (toward top-left)
if(dir===1){px=sx+t*hw;py=sy-t*hh} // E (toward top-right)
if(dir===2){px=sx+t*hw;py=sy+t*hh} // S (toward bottom-right)
if(dir===3){px=sx-t*hw;py=sy+t*hh} // W (toward bottom-left)
// Perpendicular offset for road width
let nx,ny // normal direction
if(dir===0||dir===2){nx=1/(hw/hh);ny=1} else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
if(i===0){
ctx.moveTo(px+nx*rhw,py+ny*rhw*0.5)
}
ctx.lineTo(px+nx*rhw,py+ny*rhw*0.5)
}
// Go back on the other side
for(let i=steps;i>=0;i--){
const t=i/steps*0.5
let px,py
if(dir===0){px=sx-t*hw;py=sy-t*hh}
if(dir===1){px=sx+t*hw;py=sy-t*hh}
if(dir===2){px=sx+t*hw;py=sy+t*hh}
if(dir===3){px=sx-t*hw;py=sy+t*hh}
let nx,ny
if(dir===0||dir===2){nx=1/(hw/hh);ny=1}else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
ctx.lineTo(px-nx*rhw,py-ny*rhw*0.5)
}
ctx.closePath()
ctx.fill()
}
function drawIsoRoadMarkings(sx,sy,hw,hh,rhw,dir){
// Center dashed line
ctx.strokeStyle='rgba(255,255,200,0.5)';ctx.lineWidth=Math.max(1.2,TW*0.015)
ctx.setLineDash([TW*0.06,TW*0.04])
ctx.beginPath()
let ex,ey
if(dir===0){ex=sx-hw*0.48;ey=sy-hh*0.48}
if(dir===1){ex=sx+hw*0.48;ey=sy-hh*0.48}
if(dir===2){ex=sx+hw*0.48;ey=sy+hh*0.48}
if(dir===3){ex=sx-hw*0.48;ey=sy+hh*0.48}
ctx.moveTo(sx,sy);ctx.lineTo(ex,ey);ctx.stroke()
ctx.setLineDash([])
// Edge lines (solid, thicker)
ctx.strokeStyle='rgba(255,255,255,0.3)';ctx.lineWidth=Math.max(0.8,TW*0.01)
let nx,ny
if(dir===0||dir===2){nx=1/(hw/hh);ny=1}else{nx=-1/(hw/hh);ny=1}
const len=Math.sqrt(nx*nx+ny*ny);nx/=len;ny/=len
for(const side of [1,-1]){
ctx.beginPath()
ctx.moveTo(sx+nx*rhw*side,sy+ny*rhw*0.5*side)
ctx.lineTo(ex+nx*rhw*side,ey+ny*rhw*0.5*side)
ctx.stroke()
}
}
// === UI ===
function renderAll(){
document.getElementById('h-bud').textContent=budget
document.getElementById('h-rnd').textContent=`${round}/${level.rounds}`
if(selType&&budget<TILES[selType].cost)selType=null
const ps=[{n:'Wirtschaft',i:'💰',v:scores.eco},{n:'Zufriedenheit',i:'😊',v:scores.sat},{n:'Umwelt',i:'🌿',v:scores.env},{n:'Verkehr',i:'🚗',v:scores.traf,inv:1},{n:'Versorgung',i:'🛒',v:scores.sup},{n:'Erreichbarkeit',i:'🔗',v:scores.reach}]
document.getElementById('params').innerHTML=ps.map(p=>{const g=p.inv?p.v<40:p.v>55,b=p.inv?p.v>65:p.v<30,col=b?'var(--coral)':g?'var(--moss)':'var(--sand)';return`<div class="param"><div class="param-i">${p.i}</div><div class="param-inf"><div class="param-n">${p.n}</div><div class="param-bar"><div class="param-bar-f" style="width:${p.v}%;background:${col}"></div></div></div><div class="param-v" style="color:${col}">${p.v}</div></div>`}).join('')
document.getElementById('tiles').innerHTML=level.unlocked.map(id=>{const d=TILES[id],af=budget>=d.cost;return`<div class="tb ${selType===id?'on':''} ${!af?'off':''}" data-t="${id}"><span class="te">${d.content[0]?.e||'🛣️'}</span><span class="tn">${d.n}</span><span class="tc">💰${d.cost}</span></div>`}).join('')
document.querySelectorAll('.tb').forEach(b=>b.addEventListener('click',()=>{selType=selType===b.dataset.t?null:b.dataset.t;renderAll()}))
document.getElementById('msgs').innerHTML=messages.slice(0,7).map(m=>`<div class="msg ${m.type}">${m.text}</div>`).join('')
drawGraph()
}
function drawGraph(){const gc=document.getElementById('graph');if(!gc||history.length<1)return;const gx=gc.getContext('2d'),rect=gc.parentElement.getBoundingClientRect(),dpr=devicePixelRatio||1;gc.width=rect.width*dpr;gc.height=65*dpr;gx.setTransform(dpr,0,0,dpr,0,0);const gW=rect.width,gH=65;gx.clearRect(0,0,gW,gH);const lines=[{k:'eco',c:'#c4a35a'},{k:'sat',c:'#5a8a5e'},{k:'env',c:'#4a8a4a'},{k:'sup',c:'#4a7c8a'},{k:'reach',c:'#8a6a8a'}];for(const l of lines){gx.strokeStyle=l.c;gx.lineWidth=1;gx.beginPath();history.forEach((h,i)=>{const x=2+(i/Math.max(1,level.rounds-1))*(gW-4),y=2+(gH-4)-(h[l.k]/100)*(gH-4);i===0?gx.moveTo(x,y):gx.lineTo(x,y)});gx.stroke()}}
// === INPUT ===
$cv.addEventListener('mousemove',e=>{const r=$cv.getBoundingClientRect();const g=screenToGrid(e.clientX-r.left,e.clientY-r.top);ghostR=g.r;ghostC=g.c;if(ghostR<0||ghostR>=GH||ghostC<0||ghostC>=GW){ghostR=-1;ghostC=-1}})
$cv.addEventListener('click',e=>{
if(gameOver||!selType)return;const r=$cv.getBoundingClientRect()
const g=screenToGrid(e.clientX-r.left,e.clientY-r.top)
if(g.r>=0&&g.r<GH&&g.c>=0&&g.c<GW)place(g.r,g.c)
})
$cv.addEventListener('mouseleave',()=>{ghostR=-1;ghostC=-1})
document.addEventListener('keydown',e=>{if(selType){selType=null;renderAll()}})
document.getElementById('btn-next').addEventListener('click',nextRound)
document.getElementById('btn-go').addEventListener('click',()=>{document.getElementById('ov-intro').classList.add('hidden');renderAll()})
document.getElementById('btn-again').addEventListener('click',()=>{document.getElementById('ov-end').classList.add('hidden');initGame(LEVELS[0]);renderAll()})
document.getElementById('btn-reset').addEventListener('click',()=>{if(confirm('Neu?')){initGame(LEVELS[0]);renderAll()}})
initGame(LEVELS[0]);resize();window.addEventListener('resize',resize);renderAll();draw()
</script>
</body>
</html>
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Alle 40 Stilvarianten</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#f7f6f3;color:#1a1a1a;line-height:1.6}
header{text-align:center;padding:3rem 2rem 1rem}
header h1{font-size:2.5rem;font-weight:900;letter-spacing:-0.03em}
header h1 span{color:#4a7c8a}
header .sub{color:#5a5a5a;font-size:.95rem;margin-top:.3rem}
header .count{display:inline-flex;align-items:center;gap:.4rem;margin-top:.8rem;padding:.4rem 1rem;background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:99px;font-size:.8rem;font-weight:600;color:#4a7c8a;box-shadow:0 2px 8px rgba(0,0,0,0.04)}
.filter-bar{display:flex;justify-content:center;gap:.5rem;flex-wrap:wrap;padding:1rem 2rem 2rem}
.filter-btn{padding:.4rem 1rem;border-radius:8px;font-size:.8rem;font-weight:600;border:1.5px solid rgba(0,0,0,0.08);background:#fff;color:#5a5a5a;cursor:pointer;transition:all .2s}
.filter-btn:hover,.filter-btn.active{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
h2{font-size:1.4rem;font-weight:800;max-width:1200px;margin:2rem auto .8rem;padding:0 2rem;color:#4a7c8a;border-bottom:2px solid #dae8ec;padding-bottom:.4rem}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;max-width:1200px;margin:0 auto;padding:0 2rem}
.card{background:#fff;border:2px solid rgba(0,0,0,0.04);border-radius:16px;overflow:hidden;transition:all .3s;cursor:pointer;position:relative}
.card:hover{border-color:#4a7c8a;transform:translateY(-4px);box-shadow:0 10px 30px rgba(74,124,138,0.12)}
.card.selected{border-color:#4a7c8a;box-shadow:0 0 0 3px rgba(74,124,138,0.2)}
.card.selected::after{content:'✓';position:absolute;top:.7rem;right:.7rem;background:#4a7c8a;color:#fff;width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;z-index:2}
.card-img{width:100%;aspect-ratio:1;object-fit:cover;display:block;transition:transform .5s}
.card:hover .card-img{transform:scale(1.04)}
.card-body{padding:1rem}
.card-body h3{font-size:.92rem;font-weight:700;margin-bottom:.2rem;display:flex;align-items:center;gap:.4rem}
.letter{display:inline-flex;align-items:center;justify-content:center;min-width:24px;height:24px;padding:0 5px;background:#dae8ec;color:#4a7c8a;border-radius:5px;font-size:.65rem;font-weight:800;flex-shrink:0}
.card-body p{font-size:.78rem;color:#5a5a5a;margin-bottom:.4rem}
.tags{display:flex;gap:.3rem;flex-wrap:wrap}
.tag{font-size:.65rem;padding:2px 7px;border-radius:5px;font-weight:600}
.tag-pro{background:#dceadd;color:#3a6b3e}
.tag-con{background:#f2ddd8;color:#8a3a2a}
footer{text-align:center;padding:2rem;color:#8a8a8a;font-size:.78rem;max-width:800px;margin:2rem auto 0;border-top:1px solid rgba(0,0,0,0.06)}
@media(max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:550px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<h1>🎨 Alle <span>Stilvarianten</span></h1>
<p class="sub">Welcher visuelle Stil passt am besten für ein Geografie-Lernprogramm für Jugendliche (1014)?</p>
<div class="count">50 Stile · Klicke deine Favoriten</div>
</header>
<div class="filter-bar">
<button class="filter-btn active" onclick="filterAll()">Alle 40</button>
<button class="filter-btn" onclick="filter('hell')">☀️ Hell</button>
<button class="filter-btn" onclick="filter('dunkel')">🌙 Dunkel</button>
<button class="filter-btn" onclick="filter('kunst')">🎭 Künstlerisch</button>
<button class="filter-btn" onclick="filter('wild')">🧪 Wild & Ungewöhnlich</button>
<button class="filter-btn" onclick="filter('hand')">🧶 Handarbeit</button>
</div>
<!-- ============ HELLE STILE ============ -->
<h2 id="hell">☀️ Helle Stile (16)</h2>
<div class="grid" data-cat="hell">
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-a.png" alt=""><div class="card-body"><h3><span class="letter">A</span> Isometric Low-Poly</h3><p>Geometrische 3D-Blöcke, Pastellfarben, Monument Valley trifft Schulbuch.</p><div class="tags"><span class="tag tag-pro">✓ Spielerisch</span><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-con">✗ Zu „gamig"?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-b.png" alt=""><div class="card-body"><h3><span class="letter">B</span> Flat Vector</h3><p>Kräftige Farben, klare Outlines. Notion/Stripe-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Modern</span><span class="tag tag-pro">✓ Skalierbar</span><span class="tag tag-con">✗ Nüchtern</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-d.png" alt=""><div class="card-body"><h3><span class="letter">D</span> Paper Cut-Out</h3><p>Geschichtete Papierlagen, weiche Pastelltöne, taktil. Pop-Up-Buch.</p><div class="tags"><span class="tag tag-pro">✓ Warm</span><span class="tag tag-pro">✓ Inklusiv</span><span class="tag tag-con">✗ Zu jung?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-g.png" alt=""><div class="card-body"><h3><span class="letter">G</span> 3D Clay / Claymation</h3><p>Knete-Formen, runde Ecken, warme Beleuchtung. Apple-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Zugänglich</span><span class="tag tag-pro">✓ Trend 2026</span><span class="tag tag-con">✗ Aufwändig</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-h.png" alt=""><div class="card-body"><h3><span class="letter">H</span> Minimal Line Art</h3><p>Feine schwarze Linien, ein Akzentton, viel Weißraum. Infografik-Charakter.</p><div class="tags"><span class="tag tag-pro">✓ Elegant</span><span class="tag tag-pro">✓ Barrierefrei</span><span class="tag tag-con">✗ Zu erwachsen?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-i.png" alt=""><div class="card-body"><h3><span class="letter">I</span> Soft Gradient / Bento</h3><p>Sanfte Verläufe, Apple Weather, luftig und leicht.</p><div class="tags"><span class="tag tag-pro">✓ Freundlich</span><span class="tag tag-pro">✓ Modern 2026</span><span class="tag tag-pro">✓ Lesbar</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-j.png" alt=""><div class="card-body"><h3><span class="letter">J</span> Scandinavian Minimal</h3><p>Weißraum, gedeckte Naturfarben, ruhig und vertrauenswürdig.</p><div class="tags"><span class="tag tag-pro">✓ Beruhigend</span><span class="tag tag-pro">✓ Zeitlos</span><span class="tag tag-pro">✓ Inklusiv</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-k.png" alt=""><div class="card-body"><h3><span class="letter">K</span> Friendly 3D Pastel</h3><p>Weiche 3D-Pastellformen, Headspace/Duolingo 2026.</p><div class="tags"><span class="tag tag-pro">✓ Positiv</span><span class="tag tag-pro">✓ Trend</span><span class="tag tag-pro">✓ Warm</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-l.png" alt=""><div class="card-body"><h3><span class="letter">L</span> Doodle / Sketch</h3><p>Handgezeichnet, Whiteboard-Notizen, nahbar und authentisch.</p><div class="tags"><span class="tag tag-pro">✓ Nahbar</span><span class="tag tag-pro">✓ Günstig</span><span class="tag tag-con">✗ Zu einfach?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-m.png" alt=""><div class="card-body"><h3><span class="letter">M</span> Geometric Bauhaus</h3><p>Klare Formen, helle Farben, Mondrian trifft Schulatlas.</p><div class="tags"><span class="tag tag-pro">✓ Strukturiert</span><span class="tag tag-pro">✓ Bildend</span><span class="tag tag-con">✗ Zu abstrakt?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-n.png" alt=""><div class="card-body"><h3><span class="letter">N</span> Botanical / Nature Journal</h3><p>Feine Linien, natürliche Farben, wie ein modernes Naturkundeheft.</p><div class="tags"><span class="tag tag-pro">✓ Wissenschaftlich</span><span class="tag tag-pro">✓ Edel</span><span class="tag tag-con">✗ Nicht „digital"</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-o.png" alt=""><div class="card-body"><h3><span class="letter">O</span> Glasmorphism Light</h3><p>Milchglas-Panels über Gradient. Premium, 2026.</p><div class="tags"><span class="tag tag-pro">✓ Sehr modern</span><span class="tag tag-pro">✓ Premium</span><span class="tag tag-con">✗ Lesbarkeit</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-p.png" alt=""><div class="card-body"><h3><span class="letter">P</span> Neubrutalism</h3><p>Dicke schwarze Rahmen, rohe Formen, kräftige Farben. Frech.</p><div class="tags"><span class="tag tag-pro">✓ Auffällig</span><span class="tag tag-pro">✓ Trendy</span><span class="tag tag-con">✗ Polarisierend</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-x.png" alt=""><div class="card-body"><h3><span class="letter">X</span> Origami Papier</h3><p>Gefaltete Papierlandschaften, präzise Knicke, sanfte Schatten.</p><div class="tags"><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-pro">✓ Taktil</span><span class="tag tag-con">✗ Aufwändig</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-r.png" alt=""><div class="card-body"><h3><span class="letter">R</span> Pixel Art 16-bit</h3><p>Retro-Gaming SNES. Charmant, nostalgisch, fröhlich.</p><div class="tags"><span class="tag tag-pro">✓ Spielerisch</span><span class="tag tag-pro">✓ Günstig</span><span class="tag tag-con">✗ Nicht ernst genug?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-aa.png" alt=""><div class="card-body"><h3><span class="letter">AA</span> Infographic Flat</h3><p>Dashboard-Ästhetik: Weltkarte mit Datenpunkten und Charts.</p><div class="tags"><span class="tag tag-pro">✓ Daten-affin</span><span class="tag tag-pro">✓ Klar</span><span class="tag tag-con">✗ Kalt</span></div></div></div>
</div>
<!-- ============ DUNKLE STILE ============ -->
<h2 id="dunkel">🌙 Dunkle Stile (5)</h2>
<div class="grid" data-cat="dunkel">
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-e.png" alt=""><div class="card-body"><h3><span class="letter">E</span> Neon Glassmorphism</h3><p>Dunkler Grund, leuchtende Neon-Linien, Gaming-Generation.</p><div class="tags"><span class="tag tag-pro">✓ Cool</span><span class="tag tag-pro">✓ Energetisch</span><span class="tag tag-con">✗ Dunkel</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-s.png" alt=""><div class="card-body"><h3><span class="letter">S</span> Blueprint Technical</h3><p>Weiße Linien auf tiefem Blau. Ingenieur-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Wissenschaftlich</span><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-con">✗ Einschüchternd</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-w.png" alt=""><div class="card-body"><h3><span class="letter">W</span> Chalkboard / Tafel</h3><p>Kreide auf dunkelgrüner Tafel. Nostalgisch, schulisch.</p><div class="tags"><span class="tag tag-pro">✓ Vertraut</span><span class="tag tag-con">✗ „Alte Schule"</span><span class="tag tag-con">✗ Lesbarkeit</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ad.png" alt=""><div class="card-body"><h3><span class="letter">AD</span> Cyberpunk Holographic</h3><p>Holografisch-irideszent, Glitch, Neon. Futuristisch.</p><div class="tags"><span class="tag tag-pro">✓ Wow-Faktor</span><span class="tag tag-con">✗ Dunkel</span><span class="tag tag-con">✗ Ablenkend</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-z.png" alt=""><div class="card-body"><h3><span class="letter">Z</span> Satelliten-Realismus</h3><p>NASA-Fotografie. Die Erde aus dem All. Ehrfurchtgebietend.</p><div class="tags"><span class="tag tag-pro">✓ Real</span><span class="tag tag-pro">✓ Beeindruckend</span><span class="tag tag-con">✗ Kein eigener Stil</span></div></div></div>
</div>
<!-- ============ KÜNSTLERISCHE STILE ============ -->
<h2 id="kunst">🎭 Künstlerische Stile (13)</h2>
<div class="grid" data-cat="kunst">
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-c.png" alt=""><div class="card-body"><h3><span class="letter">C</span> Retro-Futurismus / Risograph</h3><p>Körnige Texturen, 34 Farben, cooles Wissenschaftsposter.</p><div class="tags"><span class="tag tag-pro">✓ Cool-Faktor</span><span class="tag tag-pro">✓ Wiedererkennbar</span><span class="tag tag-con">✗ „Alt"?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-f.png" alt=""><div class="card-body"><h3><span class="letter">F</span> Aquarell / Watercolor</h3><p>Weiche Texturen, natürliche Farben, Naturkunde-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-pro">✓ Beruhigend</span><span class="tag tag-con">✗ Wenig „digital"</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-q.png" alt=""><div class="card-body"><h3><span class="letter">Q</span> Japanischer Holzschnitt</h3><p>Hokusai-inspiriert: Wellen, Berge, traditionelle Palette.</p><div class="tags"><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-pro">✓ Kulturbezug</span><span class="tag tag-con">✗ Aneignung?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-t.png" alt=""><div class="card-body"><h3><span class="letter">T</span> Collage / Mixed Media</h3><p>Gerissenes Papier, Foto-Fragmente, Washi-Tape. Projektboard.</p><div class="tags"><span class="tag tag-pro">✓ Kreativ</span><span class="tag tag-con">✗ Unruhig</span><span class="tag tag-con">✗ Inkonsistent</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-u.png" alt=""><div class="card-body"><h3><span class="letter">U</span> Gradient Mesh</h3><p>Fließende Farbverläufe, keine harten Kanten. Abstrakt, meditativ.</p><div class="tags"><span class="tag tag-pro">✓ Modern</span><span class="tag tag-con">✗ Wenig konkret</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-v.png" alt=""><div class="card-body"><h3><span class="letter">V</span> Buntglas / Stained Glass</h3><p>Schwarze Bleilinien, Juwelenfarben. Mittelalter trifft Modern.</p><div class="tags"><span class="tag tag-pro">✓ Dekorativ</span><span class="tag tag-con">✗ Schwer für UI</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-y.png" alt=""><div class="card-body"><h3><span class="letter">Y</span> Linolschnitt / Block Print</h3><p>Kräftig geschnitzte Formen, 23 Farben, handgemacht.</p><div class="tags"><span class="tag tag-pro">✓ Markant</span><span class="tag tag-con">✗ Zu grob für Details</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ab.png" alt=""><div class="card-body"><h3><span class="letter">AB</span> Art Nouveau / Jugendstil</h3><p>Fließende Linien, dekorative Rahmen, Mucha-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Elegant</span><span class="tag tag-con">✗ Aufwändig</span><span class="tag tag-con">✗ „Alt"?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ac.png" alt=""><div class="card-body"><h3><span class="letter">AC</span> Memphis Design (80er)</h3><p>Kräftige Geometrie, Squiggly Lines, Terrazzo. Retro-cool.</p><div class="tags"><span class="tag tag-pro">✓ Energetisch</span><span class="tag tag-con">✗ Visuell laut</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ae.png" alt=""><div class="card-body"><h3><span class="letter">AE</span> Gouache Poster Paint</h3><p>Dicke Pinselstriche, lebendige matte Farben. Handgemaltes Reiseposter.</p><div class="tags"><span class="tag tag-pro">✓ Warm</span><span class="tag tag-pro">✓ Künstlerisch</span><span class="tag tag-pro">✓ Einladend</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-af.png" alt=""><div class="card-body"><h3><span class="letter">AF</span> Topografische Karte</h3><p>Höhenlinien, Elevation-Farben. Kartografie trifft Kunst.</p><div class="tags"><span class="tag tag-pro">✓ Geo-DNA</span><span class="tag tag-pro">✓ Elegant</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ag.png" alt=""><div class="card-body"><h3><span class="letter">AG</span> Halftone / Rasterdruck</h3><p>Zeitungsdruck: Punktraster, CMYK-Versatz, editorial.</p><div class="tags"><span class="tag tag-pro">✓ Distinktiv</span><span class="tag tag-con">✗ Schwer lesbar klein</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ah.png" alt=""><div class="card-body"><h3><span class="letter">AH</span> Studio Ghibli / Anime</h3><p>Saftige Hügel, dramatische Wolken, rote Dächer. Magisch und warm.</p><div class="tags"><span class="tag tag-pro">✓ Emotional</span><span class="tag tag-pro">✓ Beliebt</span><span class="tag tag-con">✗ Lizenz-Assoziation</span></div></div></div>
</div>
<!-- ============ WILDE / UNGEWÖHNLICHE STILE ============ -->
<h2 id="wild">🧪 Wild & Ungewöhnlich (6)</h2>
<div class="grid" data-cat="wild">
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ai.png" alt=""><div class="card-body"><h3><span class="letter">AI</span> Unterwasser-Perspektive</h3><p>Blick von UNTER der Wasseroberfläche nach oben. Küstenlinie durch Wasser verzerrt, Lichtbrechung, Korallen im Vordergrund. Niemand macht das.</p><div class="tags"><span class="tag tag-pro">✓ Völlig einzigartig</span><span class="tag tag-pro">✓ Immersiv</span><span class="tag tag-con">✗ Schwer für alle Themen</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-aj.png" alt=""><div class="card-body"><h3><span class="letter">AJ</span> Röntgen / X-Ray</h3><p>Die Erde wie ein medizinischer Scan: transluzente Schichten, Städte als leuchtende Knoten, biolumineszente Ästhetik. Die Welt durchleuchtet.</p><div class="tags"><span class="tag tag-pro">✓ Wissenschafts-Wow</span><span class="tag tag-pro">✓ Surreal</span><span class="tag tag-con">✗ Dunkel</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ak.png" alt=""><div class="card-body"><h3><span class="letter">AK</span> Stickerei / Embroidery</h3><p>Weltkarte gestickt auf Leinen: Wälder als Knötchenstich, Ozeane als Plattstich, Berge als Kettenstich. Sichtbare Stofftextur, Fadenwerk. Wie Omas schönstes Stickbild.</p><div class="tags"><span class="tag tag-pro">✓ Überwältigend einzigartig</span><span class="tag tag-pro">✓ Warm</span><span class="tag tag-pro">✓ Handwerk</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-al.png" alt=""><div class="card-body"><h3><span class="letter">AL</span> Daten-Regen / Data Rain</h3><p>Die Weltkarte besteht aus fließenden Zahlenströmen: Amber für Wüste, Blau für Ozean, Grün für Wald. Die Welt IST Daten. Auf hellem Grund.</p><div class="tags"><span class="tag tag-pro">✓ Technik + Geo</span><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-con">✗ Abstrakt</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-am.png" alt=""><div class="card-body"><h3><span class="letter">AM</span> Kinderzeichnung Deluxe</h3><p>Landschaft wie von einem talentierten 8-Jährigen mit Buntstiften. Wackelige aber selbstsichere Linien, große glückliche Sonne, Strichmännchen-Bäume — aber auf Premium-Papier als Kunst gerahmt. Maximal entwaffnend.</p><div class="tags"><span class="tag tag-pro">✓ Entwaffnend</span><span class="tag tag-pro">✓ Angstfrei</span><span class="tag tag-pro">✓ Einzigartig</span><span class="tag tag-con">✗ Nicht ernst genug?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-an.png" alt=""><div class="card-body"><h3><span class="letter">AN</span> Thermografie / Wärmebild</h3><p>Infrarotkamera-Ansicht: Lila (kalt) über Blau, Grün, Gelb zu Rot/Weiß (heiß). Stadt als helle Hotspots, Fluss als kaltes blaues Band. Die Welt durch Temperatur sehen.</p><div class="tags"><span class="tag tag-pro">✓ Wissenschaftlich</span><span class="tag tag-pro">✓ Faszinierend</span><span class="tag tag-con">✗ Dunkel</span><span class="tag tag-con">✗ Einseitig</span></div></div></div>
</div>
<!-- ============ HANDARBEIT STILE ============ -->
<h2 id="hand">🧶 Handarbeit & Haptik (10)</h2>
<div class="grid" data-cat="hand">
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ao.png" alt=""><div class="card-body"><h3><span class="letter">AO</span> Zerrissene Papierfetzen</h3><p>Landschaft aus gerissenen Papierschnipseln auf weißem Grund. Sichtbare Risskanten, Klebereste, überlappende Lagen. Roh und ehrlich.</p><div class="tags"><span class="tag tag-pro">✓ Roh-authentisch</span><span class="tag tag-pro">✓ Taktil</span><span class="tag tag-con">✗ Unordentlich?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ap.png" alt=""><div class="card-body"><h3><span class="letter">AP</span> Washi Tape Landkarte</h3><p>Weltkarte aus bunten Washi-Tape-Streifen: Blumen-, Streifen-, Punkt-Muster. Tape-Kanten sichtbar. Bullet-Journal-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Zielgruppen-nah</span><span class="tag tag-pro">✓ Hübsch</span><span class="tag tag-pro">✓ Hell</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-aq.png" alt=""><div class="card-body"><h3><span class="letter">AQ</span> Filz & Stoff Applikation</h3><p>Berge aus grauem Wollfilz, Bäume aus grünen Filzkreisen, Fluss aus blauem Satinband. Sichtbare Nähte. Wie ein Quiet Book.</p><div class="tags"><span class="tag tag-pro">✓ Warm</span><span class="tag tag-pro">✓ Inklusiv</span><span class="tag tag-pro">✓ Einzigartig</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ar.png" alt=""><div class="card-body"><h3><span class="letter">AR</span> Gepresste Blumen & Botanik</h3><p>Landschaft aus echten gepressten Blumen und Blättern: Farne als Berge, Lavendel als Himmel, Gräser als Felder. Herbarium-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Museumsreif</span><span class="tag tag-pro">✓ Naturverbunden</span><span class="tag tag-con">✗ Sehr speziell</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-as.png" alt=""><div class="card-body"><h3><span class="letter">AS</span> Buntstift auf Packpapier</h3><p>Landschaft mit Buntstiften auf Packpapier gezeichnet. Sichtbare Striche, warmer Braunton scheint durch. Ehrlich wie ein Schülerheft.</p><div class="tags"><span class="tag tag-pro">✓ Nahbar</span><span class="tag tag-pro">✓ Warm</span><span class="tag tag-pro">✓ Günstig</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-at.png" alt=""><div class="card-body"><h3><span class="letter">AT</span> Kartoffelstempel</h3><p>Landschaft aus gestempelten Formen: Kreise, Dreiecke, Quadrate. Ungleichmäßige Farbe, charmante Unperfektheit. Volksschul-Energie.</p><div class="tags"><span class="tag tag-pro">✓ Spielerisch</span><span class="tag tag-pro">✓ Fröhlich</span><span class="tag tag-con">✗ Zu kindlich?</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-au.png" alt=""><div class="card-body"><h3><span class="letter">AU</span> Pappmaché Relief</h3><p>3D-Reliefkarte aus Pappmaché, von oben fotografiert. Echte Schatten, Zeitungstext scheint durch die Farbe. Werkstatt-Ästhetik.</p><div class="tags"><span class="tag tag-pro">✓ Dreidimensional</span><span class="tag tag-pro">✓ Taktil</span><span class="tag tag-con">✗ Aufwändig</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-av.png" alt=""><div class="card-body"><h3><span class="letter">AV</span> Scherenschnitt</h3><p>Filigrane Silhouette aus einem Blatt geschnitten: Berge, Bäume, Häuser, Vögel — alles verbunden. Schweizer/deutsche Volkskunst-Tradition.</p><div class="tags"><span class="tag tag-pro">✓ Elegant</span><span class="tag tag-pro">✓ Kulturell</span><span class="tag tag-pro">✓ Wiedererkennbar</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-aw.png" alt=""><div class="card-body"><h3><span class="letter">AW</span> Aquarell-Skizzenbuch</h3><p>Offenes Skizzenbuch mit schnellen Aquarell-Feldskizzen. Bleistift-Unterzeichnung sichtbar, Wasserflecken am Rand. Authentisches Feldtagebuch.</p><div class="tags"><span class="tag tag-pro">✓ Authentisch</span><span class="tag tag-pro">✓ Wissenschaftlich</span><span class="tag tag-pro">✓ Warm</span></div></div></div>
<div class="card" onclick="sel(this)"><img class="card-img" src="assets/img/style-ax.png" alt=""><div class="card-body"><h3><span class="letter">AX</span> Makramee & Textilkunst</h3><p>Landschaft aus Makramee-Knoten und Webkunst. Berge aus Knotenmuster, Ozean aus blauer Garnfranse, Sonne aus goldenem Webkreis. Boho-Handwerk.</p><div class="tags"><span class="tag tag-pro">✓ Textur-reich</span><span class="tag tag-pro">✓ Modern-Handwerk</span><span class="tag tag-con">✗ Nische</span></div></div></div>
</div>
<footer>
<p>💡 <strong>Mein Ranking für Jugendliche (1014):</strong></p>
<p>🥇 <strong>AH (Ghibli)</strong> — emotional, warm, bei der Zielgruppe extrem beliebt<br>
🥈 <strong>AK (Stickerei)</strong> — überwältigend einzigartig, warm, niemand hat sowas<br>
🥉 <strong>AE (Gouache)</strong> — handgemalt, einladend, hebt sich komplett ab<br>
4. <strong>J (Scandinavian)</strong> — zeitlos, inklusiv, unsere aktuelle Wahl für die UI<br>
5. <strong>AF (Topografisch)</strong> — fachlich perfekt, Geo-DNA pur<br>
6. <strong>AM (Kinderzeichnung)</strong> — der mutigste Move. Maximal angstfrei.</p>
<p style="margin-top:.8rem">Stile lassen sich kombinieren: z.B. Skandinavische UI + Ghibli-Themenbilder + Infografik-Daten.</p>
</footer>
<script>
function sel(el){el.classList.toggle('selected')}
function filter(cat){
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('h2, .grid').forEach(el=>{
const id = el.id || el.dataset.cat;
el.style.display = (id===cat) ? '' : 'none';
});
}
function filterAll(){
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('h2, .grid').forEach(el=>el.style.display='');
}
</script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import {
AUSTRIA, GERMANY_BAYERN, SWITZERLAND,
getLocalName, getPrimaryStages, requiresReading,
getReadingLevel, getSubjectName
} from '../../src/core/education-levels'
describe('Education Levels — Österreich', () => {
it('AT: 1. Klasse MS = Schulstufe 5', () => {
expect(getLocalName(AUSTRIA, 5)).toBe('1. Klasse')
})
it('AT: 4. Klasse MS = Schulstufe 8', () => {
expect(getLocalName(AUSTRIA, 8)).toBe('4. Klasse')
})
it('AT: Volksschule = Schulstufe 14', () => {
expect(getLocalName(AUSTRIA, 1)).toBe('1. Klasse Volksschule')
expect(getLocalName(AUSTRIA, 4)).toBe('4. Klasse Volksschule')
})
it('AT: Hauptfokus = Stufe 58 (4 Stufen)', () => {
const primary = getPrimaryStages(AUSTRIA)
expect(primary).toHaveLength(4)
expect(primary[0].level).toBe(5)
expect(primary[3].level).toBe(8)
})
it('AT: Fach heißt "Geografie und wirtschaftliche Bildung" ab Stufe 5', () => {
expect(getSubjectName(AUSTRIA, 5)).toBe('Geografie und wirtschaftliche Bildung')
})
it('AT: Fach heißt "Sachunterricht" in der Volksschule', () => {
expect(getSubjectName(AUSTRIA, 3)).toBe('Sachunterricht')
})
it('AT: Stundentafel korrekt (2-1-2-2)', () => {
const primary = getPrimaryStages(AUSTRIA)
expect(primary.map(s => s.hoursPerWeek)).toEqual([2, 1, 2, 2])
})
})
describe('Education Levels — Deutschland Bayern', () => {
it('DE-BY: Stufe 5 = "5. Jahrgangsstufe"', () => {
expect(getLocalName(GERMANY_BAYERN, 5)).toBe('5. Jahrgangsstufe')
})
it('DE-BY: Kein Geo in Stufe 6 und 9 (0 Stunden)', () => {
const stages = getPrimaryStages(GERMANY_BAYERN)
const stage6 = stages.find(s => s.level === 6)
const stage9 = stages.find(s => s.level === 9)
expect(stage6?.hoursPerWeek).toBe(0)
expect(stage9?.hoursPerWeek).toBe(0)
})
it('DE-BY: Fach heißt "Geographie"', () => {
expect(getSubjectName(GERMANY_BAYERN, 7)).toBe('Geographie')
})
})
describe('Education Levels — Schweiz', () => {
it('CH: Stufe 7 = "1. Oberstufe"', () => {
expect(getLocalName(SWITZERLAND, 7)).toBe('1. Oberstufe')
})
it('CH: Hauptfokus = Stufe 79 (Zyklus 3)', () => {
const primary = getPrimaryStages(SWITZERLAND)
expect(primary).toHaveLength(3)
expect(primary[0].level).toBe(7)
})
it('CH: Fach heißt "Räume, Zeiten, Gesellschaften" in Zyklus 3', () => {
expect(getSubjectName(SWITZERLAND, 8)).toBe('Räume, Zeiten, Gesellschaften')
})
it('CH: Fach heißt "Natur, Mensch, Gesellschaft" in Zyklus 2', () => {
expect(getSubjectName(SWITZERLAND, 5)).toBe('Natur, Mensch, Gesellschaft')
})
})
describe('Education Levels — Lesekompetenz', () => {
it('Stufe 1 (6-jährige): keine Lesekompetenz', () => {
expect(getReadingLevel(1)).toBe('none')
expect(requiresReading(1)).toBe(false)
})
it('Stufe 23 (7-9 Jahre): basale Lesekompetenz', () => {
expect(getReadingLevel(2)).toBe('basic')
expect(getReadingLevel(3)).toBe('basic')
})
it('Stufe 4+ (ab 9 Jahre): fließende Lesekompetenz', () => {
expect(getReadingLevel(4)).toBe('fluent')
expect(getReadingLevel(7)).toBe('fluent')
})
})
+178
View File
@@ -0,0 +1,178 @@
/**
* Klimawächter Mechanik-Trace
*
* Spielt das Klimawächter-Spiel rein rechnerisch über alle 75 Jahre für
* verschiedene Strategien durch und gibt die Schlüssel-Variablen als
* ASCII-Tabellen aus. Dient zur Analyse der Spielmechanik:
* - Steigt CO ohne Maßnahmen?
* - Wie reagiert die Temperatur auf verschiedene Strategien?
* - Wann wird die Stadt überflutet?
* - Sind die Maßnahmen sinnvoll dimensioniert?
*
* Wird mit `npx vitest run sim-05-trace` aufgerufen.
* Test schlägt nie fehl er druckt nur.
*/
import { describe, it } from 'vitest'
import { KlimawaechterGame, MEASURES } from '../../src/sims/sim-05-treibhaus/game'
interface TraceRow {
tick: number
co2: number
temp: number
sea: number
flooded: number
budget: number
pop: number
measures: Record<string, number>
}
interface Strategy {
name: string
buy: (g: KlimawaechterGame, tick: number) => void
}
function runStrategy(strat: Strategy): TraceRow[] {
const game = new KlimawaechterGame()
// Tutorial überspringen, in 'playing' wechseln
;(game as any).state = 'playing'
;(game as any).tutorialStep = (game as any).tutorialSteps.length
const rows: TraceRow[] = []
// Startzustand auch erfassen
const snapshot0 = (): TraceRow => ({
tick: 0,
co2: game.getResource('co2'),
temp: game.getResource('temperature'),
sea: game.getResource('sealevel'),
flooded: game.getResource('flooded'),
budget: game.getResource('budget'),
pop: game.getResource('population'),
measures: countMeasures(game),
})
rows.push(snapshot0())
// 75 Ticks (= 75 Jahre)
for (let i = 1; i <= 75; i++) {
strat.buy(game, i)
// simulateTick ist protected → cast
;(game as any).simulateTick()
;(game as any).tick = i
rows.push({
tick: i,
co2: game.getResource('co2'),
temp: game.getResource('temperature'),
sea: game.getResource('sealevel'),
flooded: game.getResource('flooded'),
budget: game.getResource('budget'),
pop: game.getResource('population'),
measures: countMeasures(game),
})
}
return rows
}
function countMeasures(game: KlimawaechterGame): Record<string, number> {
const result: Record<string, number> = {}
for (const m of MEASURES) {
result[m.id] = game.getMeasureCount(m.id)
}
return result
}
function fmtRow(r: TraceRow): string {
const m = Object.entries(r.measures).filter(([_, c]) => c > 0).map(([k, c]) => `${k}:${c}`).join(',') || '-'
return [
String(2025 + r.tick).padStart(4),
r.co2.toFixed(0).padStart(5),
r.temp.toFixed(2).padStart(6),
r.sea.toFixed(1).padStart(6),
r.flooded.toFixed(0).padStart(4),
r.budget.toFixed(0).padStart(6),
String(r.pop).padStart(6),
m.padStart(20),
].join(' │ ')
}
function printTrace(name: string, rows: TraceRow[]) {
console.log('\n═══════════════════════════════════════════════════════════════════════════════════')
console.log(`STRATEGIE: ${name}`)
console.log('═══════════════════════════════════════════════════════════════════════════════════')
console.log(' Jahr │ CO₂ │ Temp │ Meer │ Flut │ Budget │ Bev │ Maßnahmen')
console.log('──────┼───────┼────────┼───────┼──────┼────────┼────────┼──────────────────────')
// Alle 5 Jahre + erstes + letztes
for (const r of rows) {
if (r.tick === 0 || r.tick === 75 || r.tick % 5 === 0) {
console.log(fmtRow(r))
}
}
// Endbewertung
const last = rows[rows.length - 1]
const won =
last.tick >= 75 &&
last.temp < 17 &&
last.budget > 0 &&
last.flooded < 30
console.log('──────┴───────┴────────┴───────┴──────┴────────┴────────┴──────────────────────')
console.log(` Status: ${won ? '✅ GEWONNEN' : '❌ VERLOREN'}`)
}
// === Strategien ===
const strategies: Strategy[] = [
{
name: 'Nichts tun (Baseline)',
buy: () => {},
},
{
name: 'Nur Wälder (alle 3 Jahre einer)',
buy: (g, t) => { if (t % 3 === 0) g.buyMeasure('forest') },
},
{
name: 'Nur Solar (sobald leistbar)',
buy: (g) => {
while (g.getResource('budget') >= 200) {
if (!g.buyMeasure('solar')) break
}
},
},
{
name: 'Nur Wind (sobald leistbar)',
buy: (g) => {
while (g.getResource('budget') >= 400) {
if (!g.buyMeasure('wind')) break
}
},
},
{
name: 'Nur Deiche (gegen Überflutung)',
buy: (g) => {
while (g.getResource('budget') >= 300) {
if (!g.buyMeasure('dike')) break
}
},
},
{
name: 'Wälder + Solar gemischt',
buy: (g, t) => {
if (t % 4 === 0 && g.getResource('budget') >= 200) g.buyMeasure('solar')
else if (g.getResource('budget') >= 50) g.buyMeasure('forest')
},
},
{
name: 'Optimal: Solar + Wind + 1 Deich',
buy: (g, t) => {
if (t === 5 && g.getResource('budget') >= 300) g.buyMeasure('dike')
if (g.getResource('budget') >= 400) g.buyMeasure('wind')
else if (g.getResource('budget') >= 200) g.buyMeasure('solar')
},
},
]
describe('Klimawächter — Mechanik-Trace über 75 Jahre', () => {
for (const strat of strategies) {
it(`Trace: ${strat.name}`, () => {
const rows = runStrategy(strat)
printTrace(strat.name, rows)
})
}
})
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest'
import { computeTemperature, computeEffects, TreibhausSimulation } from '../../src/sims/sim-05-treibhaus/logic'
describe('Treibhauseffekt — Physik-Modell', () => {
it('berechnet korrekte Temperatur ohne Treibhauseffekt (Albedo 0.3)', () => {
// Ohne CO₂ (theoretisch 0 ppm → ln(0) ist undefiniert, daher sehr niedrig)
// Bei 1 ppm sollte Temp nahe -18°C + 33°C - großer negativer Wert sein
// Besser: Prüfen, dass 280 ppm → ~15°C ergibt
const temp = computeTemperature(280, 0.3)
expect(temp).toBeGreaterThan(13.5)
expect(temp).toBeLessThan(16) // ~14-15°C, vereinfachtes Modell
})
it('ergibt ~16°C bei aktuellem CO₂-Niveau (425 ppm)', () => {
const temp = computeTemperature(425, 0.3)
expect(temp).toBeGreaterThan(15.5)
expect(temp).toBeLessThan(17)
})
it('Verdoppelung von CO₂ erhöht Temperatur um ~3°C', () => {
const temp280 = computeTemperature(280, 0.3)
const temp560 = computeTemperature(560, 0.3)
const delta = temp560 - temp280
expect(delta).toBeCloseTo(3.0, 0.5)
})
it('höhere Albedo → kältere Temperatur', () => {
const tempLow = computeTemperature(400, 0.2)
const tempHigh = computeTemperature(400, 0.5)
expect(tempLow).toBeGreaterThan(tempHigh)
})
it('steigende CO₂ → steigende Temperatur (monoton)', () => {
const temps = [200, 300, 400, 600, 800, 1000].map(co2 => computeTemperature(co2, 0.3))
for (let i = 1; i < temps.length; i++) {
expect(temps[i]).toBeGreaterThan(temps[i - 1])
}
})
})
describe('Treibhauseffekt — Folgen-Berechnung', () => {
it('vorindustrielle Temperatur → kein Meeresspiegelanstieg', () => {
const effects = computeEffects(15)
expect(effects.seaLevelRise).toBe(0)
})
it('+2°C → messbarer Meeresspiegelanstieg', () => {
const effects = computeEffects(17)
expect(effects.seaLevelRise).toBeGreaterThan(20)
})
it('arktisches Eis sinkt mit steigender Temperatur', () => {
const ice15 = computeEffects(15).arcticIce
const ice18 = computeEffects(18).arcticIce
expect(ice15).toBeGreaterThan(ice18)
})
it('Extremereignisse steigen mit Temperatur', () => {
const events15 = computeEffects(15).extremeEvents
const events20 = computeEffects(20).extremeEvents
expect(events20).toBeGreaterThan(events15)
})
})
describe('TreibhausSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new TreibhausSimulation()
expect(sim.meta.id).toBe('sim-05')
expect(sim.meta.primaryLevel).toBe(5)
expect(sim.meta.educationLevels).toContain(5)
expect(sim.meta.tier).toBe(1)
expect(sim.meta.dpiMinuten).toBe(20)
})
it('startet mit Standardwerten', () => {
const sim = new TreibhausSimulation()
expect(sim.getVariable('co2')).toBe(425)
expect(sim.getVariable('albedo')).toBe(0.3)
})
it('berechnet Ergebnisse nach Variable-Änderung', () => {
const sim = new TreibhausSimulation()
sim.setVariable('co2', 560)
const results = sim.compute()
expect(results.temperature).toBeGreaterThan(17)
})
it('loggt Variablenänderungen im Assessment', () => {
const sim = new TreibhausSimulation()
sim.setVariable('co2', 300)
sim.setVariable('co2', 600)
const assessment = sim.getAssessmentData()
expect(assessment.processLog.length).toBeGreaterThanOrEqual(2)
expect(assessment.processLog.some(l => l.action === 'set-variable')).toBe(true)
})
it('Predict-Observe-Explain Workflow funktioniert', () => {
const sim = new TreibhausSimulation()
// Predict
sim.nextPhase() // intro → predict
sim.setPrediction('temp_at_800ppm', 'Ich glaube über 20°C')
// Simulate
sim.nextPhase() // predict → simulate
sim.setVariable('co2', 800)
// Observe
sim.nextPhase() // simulate → observe
const results = sim.compute()
expect(results.temperature).toBeDefined()
// Reflect
sim.nextPhase() // observe → reflect
sim.addReflection('Die Temperatur ist höher als ich dachte')
const assessment = sim.getAssessmentData()
expect(assessment.predictions['temp_at_800ppm']).toBeDefined()
expect(assessment.reflections.length).toBe(1)
expect(assessment.completedPhases).toContain('reflect')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { computeMagnitude, computeCityImpact, ErdbebenSimulation } from '../../src/sims/sim-07-erdbeben/logic'
describe('Erdbeben — Magnitude-Berechnung', () => {
it('geringe Spannung → niedrige Magnitude', () => {
const mag = computeMagnitude(1, 0.5)
expect(mag).toBeLessThan(4)
})
it('hohe Spannung → hohe Magnitude', () => {
const mag = computeMagnitude(5000, 1.5)
expect(mag).toBeGreaterThan(5)
})
it('Magnitude nie über 9.5', () => {
const mag = computeMagnitude(10000, 10)
expect(mag).toBeLessThanOrEqual(9.5)
})
it('härteres Gestein → stärkeres Beben bei gleicher Spannung', () => {
const soft = computeMagnitude(10, 0.5)
const hard = computeMagnitude(10, 1.5)
expect(hard).toBeGreaterThan(soft)
})
})
describe('Erdbeben — Stadtauswirkungen', () => {
it('hohe Bauqualität → weniger Schaden', () => {
const rich = computeCityImpact(7, 30, 0.9)
const poor = computeCityImpact(7, 30, 0.1)
expect(rich.damage).toBeLessThan(poor.damage)
})
it('gleiche Magnitude, gleiche Distanz, verschiedene Bauqualität → verschiedene Opferzahlen', () => {
const rich = computeCityImpact(7, 30, 0.9)
const poor = computeCityImpact(7, 30, 0.1)
expect(poor.casualties).toBeGreaterThan(rich.casualties)
})
it('größere Entfernung → weniger Schaden', () => {
const near = computeCityImpact(7, 10, 0.5)
const far = computeCityImpact(7, 300, 0.5)
expect(far.damage).toBeLessThan(near.damage)
})
it('schwaches Beben → wenig Schaden auch bei schlechter Bauqualität', () => {
const impact = computeCityImpact(3, 30, 0.1)
expect(impact.damage).toBeLessThan(20)
})
it('starkes Beben + schlechte Bauqualität → hoher Gebäudekollaps', () => {
const impact = computeCityImpact(8, 20, 0.1)
expect(impact.buildingCollapse).toBeGreaterThan(30)
})
})
describe('ErdbebenSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new ErdbebenSimulation()
expect(sim.meta.id).toBe('sim-07')
expect(sim.meta.primaryLevel).toBe(5)
expect(sim.meta.tier).toBe(1)
})
it('Spannung baut sich über Ticks auf', () => {
const sim = new ErdbebenSimulation()
sim.tick()
sim.tick()
sim.tick()
expect(sim.getStress()).toBeGreaterThan(0)
})
it('irgendwann kommt ein Erdbeben', () => {
const sim = new ErdbebenSimulation()
sim.setVariable('plateSpeed', 15)
let quake = null
for (let i = 0; i < 100 && !quake; i++) {
quake = sim.tick()
}
expect(quake).not.toBeNull()
expect(quake!.magnitude).toBeGreaterThan(0)
})
it('Vergleich der Stadtauswirkungen funktioniert', () => {
const sim = new ErdbebenSimulation()
const { cityA, cityB } = sim.compareImpact(7)
expect(cityA.type).toBe('rich')
expect(cityB.type).toBe('poor')
expect(cityB.damage).toBeGreaterThan(cityA.damage)
})
it('schnellere Platten → häufigere Beben', () => {
const slow = new ErdbebenSimulation()
slow.setVariable('plateSpeed', 2)
const fast = new ErdbebenSimulation()
fast.setVariable('plateSpeed', 15)
let slowQuakes = 0, fastQuakes = 0
for (let i = 0; i < 200; i++) {
if (slow.tick()) slowQuakes++
if (fast.tick()) fastQuakes++
}
expect(fastQuakes).toBeGreaterThan(slowQuakes)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { computeMixResult, ENERGY_SOURCES, EnergiemixSimulation } from '../../src/sims/sim-09-energiemix/logic'
describe('Energiemix — Mix-Berechnung', () => {
it('100% Kohle → hoher CO₂-Ausstoß', () => {
const result = computeMixResult({ coal: 100 })
expect(result.totalCO2).toBeGreaterThan(700)
expect(result.renewableShare).toBe(0)
})
it('100% Wind → niedriger CO₂, niedrige Zuverlässigkeit', () => {
const result = computeMixResult({ wind: 100 })
expect(result.totalCO2).toBeLessThan(20)
expect(result.totalReliability).toBeLessThan(0.5)
})
it('100% Solar → billigste Option', () => {
const solarCost = computeMixResult({ solar: 100 }).totalCost
const coalCost = computeMixResult({ coal: 100 }).totalCost
expect(solarCost).toBeLessThan(coalCost)
})
it('gemischter Mix → Werte dazwischen', () => {
const result = computeMixResult({ coal: 30, wind: 30, solar: 20, hydro: 20 })
expect(result.totalCO2).toBeGreaterThan(50)
expect(result.totalCO2).toBeLessThan(400)
expect(result.renewableShare).toBe(70)
})
it('Erneuerbare-Anteil wird korrekt berechnet', () => {
const result = computeMixResult({ wind: 50, solar: 50 })
expect(result.renewableShare).toBe(100)
})
it('Score bevorzugt saubere + zuverlässige + günstige Mixes', () => {
const dirty = computeMixResult({ coal: 100 })
const balanced = computeMixResult({ nuclear: 30, wind: 30, hydro: 20, solar: 20 })
expect(balanced.score).toBeGreaterThan(dirty.score)
})
it('leerer Mix gibt Nullwerte', () => {
const result = computeMixResult({})
expect(result.totalCO2).toBe(0)
})
})
describe('Energiemix — Quellenddaten', () => {
it('hat 6 Energiequellen', () => {
expect(ENERGY_SOURCES).toHaveLength(6)
})
it('Wind und Solar sind erneuerbar', () => {
const wind = ENERGY_SOURCES.find(s => s.id === 'wind')!
const solar = ENERGY_SOURCES.find(s => s.id === 'solar')!
expect(wind.renewable).toBe(true)
expect(solar.renewable).toBe(true)
})
it('Kohle hat den höchsten CO₂-Wert', () => {
const coal = ENERGY_SOURCES.find(s => s.id === 'coal')!
const maxCO2 = Math.max(...ENERGY_SOURCES.map(s => s.co2PerGWh))
expect(coal.co2PerGWh).toBe(maxCO2)
})
})
describe('EnergiemixSimulation — Klasse', () => {
it('hat korrekte Metadaten', () => {
const sim = new EnergiemixSimulation()
expect(sim.meta.id).toBe('sim-09')
expect(sim.meta.primaryLevel).toBe(6)
})
it('Startwerte summieren sich auf ~100%', () => {
const sim = new EnergiemixSimulation()
const total = ENERGY_SOURCES.reduce((s, src) => s + sim.getVariable(src.id), 0)
expect(total).toBe(100)
})
it('compute() gibt sinnvolle Werte', () => {
const sim = new EnergiemixSimulation()
const result = sim.compute()
expect(result.totalCO2).toBeGreaterThan(0)
expect(result.totalCost).toBeGreaterThan(0)
})
it('Variablenänderung loggt im Assessment', () => {
const sim = new EnergiemixSimulation()
sim.setVariable('coal', 0)
sim.setVariable('solar', 50)
const assessment = sim.getAssessmentData()
expect(assessment.processLog.length).toBeGreaterThanOrEqual(2)
})
})
+216
View File
@@ -0,0 +1,216 @@
/**
* Tests fuer SIM-12: Flussmanagement
*/
import { describe, it, expect } from 'vitest'
import {
simulateRound, computeScore, computeCost, computeTotalCost,
checkWinLose, checkFinalWin,
LEVELS, CONTROL_META, STATE_META,
type Controls, type State, type Conditions,
} from '../../src/sims/sim-12-fluss/logic'
import { FlussGame } from '../../src/sims/sim-12-fluss/game'
// Hilfsfunktion: Default-Zustand
function defaultState(): State {
return {
floodLocal: 50, floodDownstream: 40, erosion: 30,
soilFertility: 60, biodiversity: 60, groundwater: 55,
usableLand: 50, economy: 50,
}
}
function emptyControls(): Controls {
return {
straightening: 0, levees: 0, dredging: 0,
floodplainRelease: 0, renaturation: 0, irrigation: 0,
}
}
function defaultConditions(): Conditions {
return { rainfall: 60, extremeWeather: 30, slope: 40, populationPressure: 50, budget: 150 }
}
describe('Fluss-Simulation Logik', () => {
describe('simulateRound', () => {
it('gibt einen neuen State zurueck (Immutabilitaet)', () => {
const state = defaultState()
const result = simulateRound(state, emptyControls(), defaultConditions())
expect(result).not.toBe(state) // Neues Objekt
})
it('ohne Massnahmen aendert sich wenig', () => {
const state = defaultState()
const result = simulateRound(state, emptyControls(), defaultConditions())
// Werte sollten nah am Ausgangszustand sein
for (const key of Object.keys(state) as (keyof State)[]) {
expect(Math.abs(result[key] - state[key])).toBeLessThan(10)
}
})
it('Deiche reduzieren lokales Hochwasser', () => {
const state = defaultState()
const controls = { ...emptyControls(), levees: 80 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodLocal).toBeLessThan(state.floodLocal)
})
it('Begradigung erhoeht Hochwasser flussabwaerts', () => {
const state = defaultState()
const controls = { ...emptyControls(), straightening: 80 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodDownstream).toBeGreaterThan(state.floodDownstream)
})
it('Renaturierung erhoeht Biodiversitaet', () => {
const state = defaultState()
const controls = { ...emptyControls(), renaturation: 70 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.biodiversity).toBeGreaterThan(state.biodiversity)
})
it('Bewaesserung erhoeht Bodenfruchtbarkeit', () => {
const state = defaultState()
const controls = { ...emptyControls(), irrigation: 60 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.soilFertility).toBeGreaterThan(state.soilFertility)
})
it('Auen freigeben reduziert Hochwasser lokal und flussabwaerts', () => {
const state = defaultState()
const controls = { ...emptyControls(), floodplainRelease: 70 }
const result = simulateRound(state, controls, defaultConditions())
expect(result.floodLocal).toBeLessThan(state.floodLocal)
expect(result.floodDownstream).toBeLessThan(state.floodDownstream)
})
it('alle Werte bleiben zwischen 0 und 100', () => {
// Extreme Kontrollen
const controls: Controls = {
straightening: 100, levees: 100, dredging: 100,
floodplainRelease: 100, renaturation: 100, irrigation: 100,
}
const result = simulateRound(defaultState(), controls, defaultConditions())
for (const key of Object.keys(result) as (keyof State)[]) {
expect(result[key]).toBeGreaterThanOrEqual(0)
expect(result[key]).toBeLessThanOrEqual(100)
}
})
it('Hochwasser-Event erhoeht Floodwerte', () => {
const state = defaultState()
const event = { round: 1, type: 'flood_event' as const, intensity: 80 }
const result = simulateRound(state, emptyControls(), defaultConditions(), event)
expect(result.floodLocal).toBeGreaterThan(state.floodLocal)
})
it('Duerre-Event reduziert Grundwasser', () => {
const state = defaultState()
const event = { round: 1, type: 'drought' as const, intensity: 70 }
const result = simulateRound(state, emptyControls(), defaultConditions(), event)
expect(result.groundwater).toBeLessThan(state.groundwater)
})
})
describe('computeScore', () => {
it('berechnet alle Scores zwischen 0 und 100', () => {
const score = computeScore(defaultState(), { safety: 0.25, ecology: 0.25, agriculture: 0.25, economy: 0.25 })
expect(score.safety).toBeGreaterThanOrEqual(0)
expect(score.safety).toBeLessThanOrEqual(100)
expect(score.ecology).toBeGreaterThanOrEqual(0)
expect(score.total).toBeGreaterThanOrEqual(0)
expect(score.total).toBeLessThanOrEqual(100)
})
it('Gewichtung beeinflusst Gesamtscore', () => {
const state: State = { ...defaultState(), economy: 90 }
const scoreEco = computeScore(state, { safety: 0, ecology: 0, agriculture: 0, economy: 1 })
const scoreSafe = computeScore(state, { safety: 1, ecology: 0, agriculture: 0, economy: 0 })
expect(scoreEco.total).not.toEqual(scoreSafe.total)
})
})
describe('computeCost', () => {
it('0 Intensitaet kostet 0', () => {
expect(computeCost('levees', 0)).toBe(0)
})
it('hoehere Intensitaet kostet mehr (ueberproportional)', () => {
const cost50 = computeCost('levees', 50)
const cost100 = computeCost('levees', 100)
expect(cost100).toBeGreaterThan(cost50 * 1.5) // ueberproportional
})
})
describe('Levels', () => {
it('hat 5 Levels', () => {
expect(LEVELS).toHaveLength(5)
})
it('jedes Level hat gueltige Struktur', () => {
for (const l of LEVELS) {
expect(l.id).toBeTruthy()
expect(l.title).toBeTruthy()
expect(l.rounds).toBeGreaterThan(0)
expect(l.allowedControls.length).toBeGreaterThan(0)
expect(l.conditions.budget).toBeGreaterThan(0)
}
})
it('keine dominante Strategie: extreme Massnahme hat Nachteile', () => {
// Teste ob maximale Begradigung nicht alles verbessert
const state = LEVELS[3].initialState // Level 4 (Gleichgewicht)
const conditions = LEVELS[3].conditions
const extremeControls = { ...emptyControls(), straightening: 100 }
const result = simulateRound({ ...state }, extremeControls, conditions)
// Begradigung muss MINDESTENS einen Nachteil haben
const hasDownside =
result.biodiversity < state.biodiversity ||
result.floodDownstream > state.floodDownstream ||
result.groundwater < state.groundwater ||
result.erosion > state.erosion
expect(hasDownside).toBe(true)
})
})
describe('CONTROL_META / STATE_META', () => {
it('hat Metadaten fuer alle 6 Massnahmen', () => {
expect(Object.keys(CONTROL_META)).toHaveLength(6)
})
it('hat Metadaten fuer alle 8 Zustandsparameter', () => {
expect(Object.keys(STATE_META)).toHaveLength(8)
})
})
})
describe('FlussGame', () => {
it('startet mit level-select Phase', () => {
const game = new FlussGame()
expect(game.phase).toBe('level-select')
})
it('selectLevel setzt korrekt auf', () => {
const game = new FlussGame()
game.selectLevel('L1')
expect(game.level.id).toBe('L1')
expect(game.phase).toBe('intro')
expect(game.round).toBe(0)
expect(game.budgetRemaining).toBe(120) // L1 budget
})
it('serialize / deserialize roundtrip', () => {
const game = new FlussGame()
game.selectLevel('L2')
game.startPlaying()
game.setControl('levees', 40)
game.executeRound()
const json = game.serialize()
const game2 = new FlussGame()
expect(game2.deserialize(json)).toBe(true)
expect(game2.level.id).toBe('L2')
expect(game2.round).toBe(1)
})
})
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kachel-Test</title>
<style>
body { margin:0; display:flex; align-items:center; justify-content:center; height:100vh; background:#e8e8e0; }
canvas { background:#c8d4b0; }
</style>
</head>
<body>
<canvas id="cv" width="400" height="400"></canvas>
<script>
var cv = document.getElementById('cv')
var c = cv.getContext('2d')
var S = 300 // Kachelgroesse (Quadrat)
var x0 = 50 // Kachel links oben
var y0 = 50
var RW = S / 3 // Strassenbreite = 1/3 der Kachel
// === GRUENE GRUNDFLAECHE ===
c.fillStyle = '#7da84e'
c.fillRect(x0, y0, S, S)
c.strokeStyle = 'rgba(0,0,0,0.12)'
c.lineWidth = 1
c.strokeRect(x0, y0, S, S)
// === STRASSE NACH SUEDEN (von Mitte nach unten) ===
var roadX = x0 + S/2 - RW/2 // Strasse horizontal zentriert
var roadY = y0 + S/2 - RW/2 // Startet in der Mitte
c.fillStyle = '#3a3a3a'
c.fillRect(roadX, roadY, RW, S/2 + RW/2) // Von Mitte bis Unterkante
// === MITTELLINIE (gestrichelt) ===
c.strokeStyle = 'rgba(255,255,180,0.7)'
c.lineWidth = 3
c.setLineDash([16, 12])
c.beginPath()
c.moveTo(x0 + S/2, y0 + S/2)
c.lineTo(x0 + S/2, y0 + S)
c.stroke()
c.setLineDash([])
// === RANDLINIEN (solid, weiss) ===
c.strokeStyle = 'rgba(255,255,255,0.5)'
c.lineWidth = 2
// Links
c.beginPath()
c.moveTo(roadX, roadY)
c.lineTo(roadX, y0 + S)
c.stroke()
// Rechts
c.beginPath()
c.moveTo(roadX + RW, roadY)
c.lineTo(roadX + RW, y0 + S)
c.stroke()
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@core/*": ["src/core/*"],
"@sims/*": ["src/sims/*"],
"@ui/*": ["src/ui/*"],
"@styles/*": ["src/styles/*"]
}
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
base: '/geograsim/App/',
resolve: {
alias: {
'@core': resolve(__dirname, 'src/core'),
'@sims': resolve(__dirname, 'src/sims'),
'@ui': resolve(__dirname, 'src/ui'),
'@styles': resolve(__dirname, 'src/styles'),
},
},
server: {
port: 3000,
open: true,
},
build: {
outDir: 'dist',
sourcemap: true,
manifest: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
game: resolve(__dirname, 'game.html'),
game3d: resolve(__dirname, 'game-3d.html'),
erdbeben: resolve(__dirname, 'erdbeben.html'),
energiemix: resolve(__dirname, 'energiemix.html'),
lieferketten: resolve(__dirname, 'lieferketten.html'),
regenwald: resolve(__dirname, 'regenwald.html'),
sim: resolve(__dirname, 'sim.html'),
dashboard: resolve(__dirname, 'dashboard.html'),
stilauswahl: resolve(__dirname, 'stilauswahl.html'),
fluss: resolve(__dirname, 'fluss.html'),
},
},
},
test: {
globals: true,
environment: 'jsdom',
},
})
+140
View File
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Projektübersicht</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',system-ui,sans-serif; background:#f7f6f3; color:#1a1a1a; padding:2rem; line-height:1.6; }
.container { max-width:1100px; margin:0 auto; }
h1 { font-size:2.4rem; font-weight:900; letter-spacing:-.03em; margin-bottom:.3rem; }
h1 span { color:#4a7c8a; }
.lead { color:#5a5a5a; margin-bottom:2rem; font-size:1rem; max-width:600px; }
h2 { font-size:1.2rem; font-weight:800; color:#4a7c8a; margin:2rem 0 .8rem; padding-bottom:.4rem; border-bottom:2px solid #dae8ec; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem; }
.card { background:#fff; border-radius:14px; border:1px solid rgba(0,0,0,.05); padding:1.2rem; box-shadow:0 2px 12px rgba(0,0,0,.04); text-decoration:none; color:inherit; transition:all .25s; display:block; }
.card:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(74,124,138,.12); border-color:rgba(74,124,138,.2); }
.card-icon { font-size:1.8rem; margin-bottom:.5rem; }
.card h3 { font-size:1rem; font-weight:700; margin-bottom:.2rem; }
.card p { font-size:.82rem; color:#6a6a6a; }
.card-meta { font-size:.7rem; color:#8a8a8a; margin-top:.5rem; font-weight:600; text-transform:uppercase; letter-spacing:.06em; }
footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid rgba(0,0,0,.05); color:#8a8a8a; font-size:.78rem; text-align:center; }
</style>
</head>
<body>
<div class="container">
<h1>Geo<span>Gra</span>Sim</h1>
<p class="lead">Interaktive Simulationen für den Geografieunterricht der Sekundarstufe I.<br>Projektübersicht und alle Deliverables.</p>
<h2>🚀 App</h2>
<div class="grid">
<a href="App/index.html" class="card">
<div class="card-icon">🌍</div>
<h3>Landing Page</h3>
<p>Skandinavische Hauptseite mit animierter Fjord-Szene und allen Modulen.</p>
<div class="card-meta">Vite Dev Server: localhost:3030</div>
</a>
<a href="App/sim.html?id=05" class="card">
<div class="card-icon">🌡️</div>
<h3>Treibhauseffekt-Simulator</h3>
<p>SIM-05 · Erste spielbare Simulation mit Canvas-Visualisierung und POE-Workflow.</p>
<div class="card-meta">14 Unit-Tests</div>
</a>
<a href="App/sim.html?id=07" class="card">
<div class="card-icon">🌋</div>
<h3>Erdbeben-Simulator</h3>
<p>SIM-07 · Plattentektonik mit Spannungsaufbau, Beben und Stadt-Vergleich.</p>
<div class="card-meta">14 Unit-Tests</div>
</a>
<a href="App/sim.html?id=09" class="card">
<div class="card-icon"></div>
<h3>Energiemix-Simulator</h3>
<p>SIM-09 · Donut-Chart mit 6 Energiequellen, Score und Trade-offs.</p>
<div class="card-meta">14 Unit-Tests</div>
</a>
<a href="App/fluss.html" class="card">
<div class="card-icon">🏞️</div>
<h3>Flussmanagement</h3>
<p>SIM-12 · Rundenbasiert: Deiche, Renaturierung, Bewässerung. 5 Levels mit didaktischer Progression.</p>
<div class="card-meta">5 Levels · 1035 min</div>
</a>
<a href="App/dashboard.html" class="card">
<div class="card-icon">📊</div>
<h3>Lehrkräfte-Dashboard</h3>
<p>Mockup: Klassenübersicht, Live-Tracking, Fehlkonzept-Erkennung, Modul-Freigabe.</p>
<div class="card-meta">Wireframe / Demo</div>
</a>
<a href="App/stilauswahl.html" class="card">
<div class="card-icon">🎨</div>
<h3>Stilauswahl</h3>
<p>50 Grafikstile zur Auswahl, gefiltert nach hell, dunkel, künstlerisch, wild, handarbeit.</p>
<div class="card-meta">50 DALL-E Bilder</div>
</a>
</div>
<h2>📚 Forschung</h2>
<div class="grid">
<a href="claude-research-sim/standalone.html" class="card">
<div class="card-icon">🔬</div>
<h3>Simulationsforschung</h3>
<p>10 Kapitel zur Wirksamkeit, Gestaltung und Qualität von Lernsimulationen.</p>
<div class="card-meta">195 Quellen · 280k Zeichen</div>
</a>
<a href="claude-research-sim/quellen.html" class="card">
<div class="card-icon">📑</div>
<h3>Quellen Simulation</h3>
<p>Quellenverzeichnis mit Bewertung, Filter und DOI-Links.</p>
<div class="card-meta">Filtrierbar</div>
</a>
<a href="claude-research/standalone.html" class="card">
<div class="card-icon">🎓</div>
<h3>GW-Didaktik</h3>
<p>10 Kapitel zur Geografiedidaktik in der Sekundarstufe I, Fokus Österreich/DACH.</p>
<div class="card-meta">135 Quellen · 194k Zeichen</div>
</a>
<a href="claude-research/quellen.html" class="card">
<div class="card-icon">📑</div>
<h3>Quellen GW-Didaktik</h3>
<p>Quellenverzeichnis mit Bewertung und Lehrplanbezug.</p>
<div class="card-meta">Filtrierbar</div>
</a>
</div>
<h2>📋 Konzept</h2>
<div class="grid">
<a href="Konzept/projekt-todos.html" class="card">
<div class="card-icon"></div>
<h3>Projekt-To-Dos</h3>
<p>Aktueller Projektstatus, To-Dos, Ideen und Designentscheidungen.</p>
</a>
<a href="Konzept/lehrplan-gw.html" class="card">
<div class="card-icon">📜</div>
<h3>Lehrplan GW</h3>
<p>Aufbereiteter österreichischer Lehrplan für Geografie und wirtschaftliche Bildung.</p>
<div class="card-meta">BGBl. II Nr. 178/2025</div>
</a>
<a href="Konzept/lehrplan-simulation-mapping.html" class="card">
<div class="card-icon">🗺️</div>
<h3>Lehrplan-Simulation-Mapping</h3>
<p>24 Simulationsmodule, jedem Kompetenzbereich des Lehrplans zugeordnet.</p>
<div class="card-meta">14 Kompetenzbereiche</div>
</a>
<a href="Konzept/lehrplanvergleich-dach.md" class="card">
<div class="card-icon">🇪🇺</div>
<h3>DACH-Lehrplanvergleich</h3>
<p>Vergleich AT, DE (BY/NRW/BW), CH, UK. 6 Tier-1-Themen identifiziert.</p>
</a>
</div>
<footer>
<p>GeoGraSim — Schroffenegger & Mößlang, 2026</p>
<p style="margin-top:.3rem">330 Forschungsquellen · 3 spielbare Simulationen · 59 Unit-Tests · 50 Grafikstile</p>
</footer>
</div>
</body>
</html>