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:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
App/node_modules/
|
||||||
|
App/dist/
|
||||||
|
App/.env.local
|
||||||
|
App/.env.production
|
||||||
|
App/Don_t_Deploy/.env
|
||||||
|
.claude/
|
||||||
@@ -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, 10–15 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 (10–15 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 Mensch–Umwelt-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 10–15 Minuten
|
||||||
|
Standardlevel 20–30 Minuten
|
||||||
|
Langlevel 30–40 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 (0–100)
|
||||||
|
hat Kosten
|
||||||
|
hat positive und negative Effekte
|
||||||
|
8. Zustandsparameter (Simulation)
|
||||||
|
|
||||||
|
Alle Werte liegen im Bereich 0–100:
|
||||||
|
|
||||||
|
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 10–40 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
|
||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
# GeoGraSim — Environment Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_NAME=geograsim
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=
|
||||||
|
DB_PORT=3306
|
||||||
@@ -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]
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+460
@@ -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
@@ -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, 20–30 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">⏱ 10–35 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>
|
||||||
@@ -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ück zur Startseite</a></p></body></html>';
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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()]);
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -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);
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 . '/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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`: 1–13 (= 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/** 2–3 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, 1–13)
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: 2025–2100 (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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* SIM-12: Flussmanagement — Simulationslogik
|
||||||
|
*
|
||||||
|
* Nichtlineares Simulationsmodell fuer Flussmanagement.
|
||||||
|
* 6 Steuermassnahmen → 8 Zustandsparameter → 4 Zielbereiche.
|
||||||
|
*
|
||||||
|
* Alle Werte liegen im Bereich 0–100.
|
||||||
|
* Abnehmender Grenznutzen, ueberproportionale Nebenwirkungen bei Extremen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === Typen ===
|
||||||
|
|
||||||
|
export interface Controls {
|
||||||
|
straightening: number // Flussbegradigung (0–100)
|
||||||
|
levees: number // Daemme/Deiche (0–100)
|
||||||
|
dredging: number // Ausbaggern (0–100)
|
||||||
|
floodplainRelease: number // Auen freigeben (0–100)
|
||||||
|
renaturation: number // Renaturierung (0–100)
|
||||||
|
irrigation: number // Bewaesserung (0–100)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 0–100)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 // 0–100
|
||||||
|
}
|
||||||
|
|
||||||
|
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 0–100 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 (0–100 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' },
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Info-Overlay — Erklärende Begriffs-Popups für Kinder (10–14 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.
|
||||||
|
* 1–2 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 Durchschnittstemperatur 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
+450
@@ -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>
|
||||||
@@ -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 (10–14)?</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, 3–4 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, 2–3 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 (10–14):</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>
|
||||||
@@ -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 1–4', () => {
|
||||||
|
expect(getLocalName(AUSTRIA, 1)).toBe('1. Klasse Volksschule')
|
||||||
|
expect(getLocalName(AUSTRIA, 4)).toBe('4. Klasse Volksschule')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AT: Hauptfokus = Stufe 5–8 (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 7–9 (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 2–3 (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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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 · 10–35 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>
|
||||||
Reference in New Issue
Block a user