From f22c5ebbfe442258ee65a840864bc1fe410f35b7 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 13 Apr 2026 16:43:42 +0200 Subject: [PATCH] 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) --- .gitignore | 7 + .humanInput/FlussSimulation | 594 ++++ .humanInput/StadtRaumplanung | 14 + .humanInput/stadtsimulation | 1126 ++++++++ App/.env.example | 6 + App/.htaccess | 18 + App/dashboard.html | 297 ++ App/energiemix.html | 239 ++ App/erdbeben.html | 230 ++ App/fluss.html | 859 ++++++ App/game-3d.html | 1239 ++++++++ App/game.html | 460 +++ App/index.html | 574 ++++ App/index.php | 56 + App/lieferketten.html | 545 ++++ App/package.json | 33 + App/pages/dashboard.php | 5 + App/pages/energiemix.php | 5 + App/pages/erdbeben.php | 5 + App/pages/fluss.php | 5 + App/pages/game-3d.php | 18 + App/pages/game.php | 18 + App/pages/index.php | 21 + App/pages/lieferketten.php | 5 + App/pages/regenwald.php | 5 + App/pages/sim.php | 5 + App/pages/stilauswahl.php | 5 + App/php/api/assessment.php | 41 + App/php/api/dashboard.php | 68 + App/php/api/saves.php | 44 + App/php/api/sessions.php | 69 + App/php/config/app.php | 38 + App/php/config/db.php | 19 + App/php/lib/Database.php | 47 + App/php/lib/Response.php | 21 + App/php/lib/Session.php | 88 + App/php/templates/_scripts.php | 43 + App/regenwald.html | 518 ++++ App/schema.sql | 83 + App/sim.html | 288 ++ App/src/core/education-levels.ts | 186 ++ App/src/core/game-engine.ts | 514 ++++ App/src/core/persistence.ts | 108 + App/src/core/router.ts | 69 + App/src/core/simulation.ts | 159 + App/src/main.ts | 11 + .../sim-05-treibhaus-3d/game-renderer-3d.ts | 2557 +++++++++++++++++ .../sims/sim-05-treibhaus/game-renderer.ts | 806 ++++++ App/src/sims/sim-05-treibhaus/game.ts | 1091 +++++++ App/src/sims/sim-05-treibhaus/logic.ts | 137 + App/src/sims/sim-05-treibhaus/renderer.ts | 345 +++ App/src/sims/sim-07-erdbeben/game-renderer.ts | 504 ++++ App/src/sims/sim-07-erdbeben/game.ts | 456 +++ App/src/sims/sim-07-erdbeben/logic.ts | 195 ++ App/src/sims/sim-07-erdbeben/renderer.ts | 467 +++ .../sims/sim-08-energiemix/game-renderer.ts | 587 ++++ App/src/sims/sim-08-energiemix/game.ts | 481 ++++ App/src/sims/sim-09-energiemix/logic.ts | 188 ++ App/src/sims/sim-09-energiemix/renderer.ts | 236 ++ App/src/sims/sim-10-lieferketten/game.ts | 615 ++++ App/src/sims/sim-10-lieferketten/renderer.ts | 269 ++ App/src/sims/sim-11-regenwald/data.ts | 222 ++ App/src/sims/sim-12-fluss/game.ts | 226 ++ App/src/sims/sim-12-fluss/logic.ts | 444 +++ App/src/sims/sim-12-fluss/renderer.ts | 352 +++ App/src/styles/base.css | 110 + App/src/styles/tokens.css | 90 + App/src/ui/animations/scenic-bg.ts | 332 +++ App/src/ui/components/sim-shell.ts | 166 ++ App/src/ui/game-ui.ts | 965 +++++++ App/src/ui/info-overlay.ts | 304 ++ App/stadt.html | 450 +++ App/stilauswahl.html | 215 ++ App/tests/unit/education-levels.test.ts | 100 + App/tests/unit/sim-05-trace.test.ts | 178 ++ App/tests/unit/sim-05-treibhaus.test.ts | 125 + App/tests/unit/sim-07-erdbeben.test.ts | 108 + App/tests/unit/sim-09-energiemix.test.ts | 96 + App/tests/unit/sim-12-fluss.test.ts | 216 ++ App/tile-test.html | 60 + App/tsconfig.json | 26 + App/vite.config.ts | 42 + index.html | 140 + 83 files changed, 22709 insertions(+) create mode 100644 .gitignore create mode 100644 .humanInput/FlussSimulation create mode 100644 .humanInput/StadtRaumplanung create mode 100644 .humanInput/stadtsimulation create mode 100644 App/.env.example create mode 100644 App/.htaccess create mode 100644 App/dashboard.html create mode 100644 App/energiemix.html create mode 100644 App/erdbeben.html create mode 100644 App/fluss.html create mode 100644 App/game-3d.html create mode 100644 App/game.html create mode 100644 App/index.html create mode 100644 App/index.php create mode 100644 App/lieferketten.html create mode 100644 App/package.json create mode 100644 App/pages/dashboard.php create mode 100644 App/pages/energiemix.php create mode 100644 App/pages/erdbeben.php create mode 100644 App/pages/fluss.php create mode 100644 App/pages/game-3d.php create mode 100644 App/pages/game.php create mode 100644 App/pages/index.php create mode 100644 App/pages/lieferketten.php create mode 100644 App/pages/regenwald.php create mode 100644 App/pages/sim.php create mode 100644 App/pages/stilauswahl.php create mode 100644 App/php/api/assessment.php create mode 100644 App/php/api/dashboard.php create mode 100644 App/php/api/saves.php create mode 100644 App/php/api/sessions.php create mode 100644 App/php/config/app.php create mode 100644 App/php/config/db.php create mode 100644 App/php/lib/Database.php create mode 100644 App/php/lib/Response.php create mode 100644 App/php/lib/Session.php create mode 100644 App/php/templates/_scripts.php create mode 100644 App/regenwald.html create mode 100644 App/schema.sql create mode 100644 App/sim.html create mode 100644 App/src/core/education-levels.ts create mode 100644 App/src/core/game-engine.ts create mode 100644 App/src/core/persistence.ts create mode 100644 App/src/core/router.ts create mode 100644 App/src/core/simulation.ts create mode 100644 App/src/main.ts create mode 100644 App/src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts create mode 100644 App/src/sims/sim-05-treibhaus/game-renderer.ts create mode 100644 App/src/sims/sim-05-treibhaus/game.ts create mode 100644 App/src/sims/sim-05-treibhaus/logic.ts create mode 100644 App/src/sims/sim-05-treibhaus/renderer.ts create mode 100644 App/src/sims/sim-07-erdbeben/game-renderer.ts create mode 100644 App/src/sims/sim-07-erdbeben/game.ts create mode 100644 App/src/sims/sim-07-erdbeben/logic.ts create mode 100644 App/src/sims/sim-07-erdbeben/renderer.ts create mode 100644 App/src/sims/sim-08-energiemix/game-renderer.ts create mode 100644 App/src/sims/sim-08-energiemix/game.ts create mode 100644 App/src/sims/sim-09-energiemix/logic.ts create mode 100644 App/src/sims/sim-09-energiemix/renderer.ts create mode 100644 App/src/sims/sim-10-lieferketten/game.ts create mode 100644 App/src/sims/sim-10-lieferketten/renderer.ts create mode 100644 App/src/sims/sim-11-regenwald/data.ts create mode 100644 App/src/sims/sim-12-fluss/game.ts create mode 100644 App/src/sims/sim-12-fluss/logic.ts create mode 100644 App/src/sims/sim-12-fluss/renderer.ts create mode 100644 App/src/styles/base.css create mode 100644 App/src/styles/tokens.css create mode 100644 App/src/ui/animations/scenic-bg.ts create mode 100644 App/src/ui/components/sim-shell.ts create mode 100644 App/src/ui/game-ui.ts create mode 100644 App/src/ui/info-overlay.ts create mode 100644 App/stadt.html create mode 100644 App/stilauswahl.html create mode 100644 App/tests/unit/education-levels.test.ts create mode 100644 App/tests/unit/sim-05-trace.test.ts create mode 100644 App/tests/unit/sim-05-treibhaus.test.ts create mode 100644 App/tests/unit/sim-07-erdbeben.test.ts create mode 100644 App/tests/unit/sim-09-energiemix.test.ts create mode 100644 App/tests/unit/sim-12-fluss.test.ts create mode 100644 App/tile-test.html create mode 100644 App/tsconfig.json create mode 100644 App/vite.config.ts create mode 100644 index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a81401 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +App/node_modules/ +App/dist/ +App/.env.local +App/.env.production +App/Don_t_Deploy/.env +.claude/ diff --git a/.humanInput/FlussSimulation b/.humanInput/FlussSimulation new file mode 100644 index 0000000..7d56301 --- /dev/null +++ b/.humanInput/FlussSimulation @@ -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; + + scoreWeights: { + safety: number; + ecology: number; + agriculture: number; + economy: number; + }; + + rounds: number; + + events?: LevelEvent[]; + + winConditions: { + minScore?: number; + targetScores?: Partial; + }; + + 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 \ No newline at end of file diff --git a/.humanInput/StadtRaumplanung b/.humanInput/StadtRaumplanung new file mode 100644 index 0000000..7d464ec --- /dev/null +++ b/.humanInput/StadtRaumplanung @@ -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 diff --git a/.humanInput/stadtsimulation b/.humanInput/stadtsimulation new file mode 100644 index 0000000..2c8f944 --- /dev/null +++ b/.humanInput/stadtsimulation @@ -0,0 +1,1126 @@ +Gut. Dann setze ich jetzt **Pflichtenheft V2** auf: **didaktisch sauber, technisch klar, bewusst einfacher als SimCity**, aber so präzise, dass eine KI es sauber umsetzen kann. + +--- + +# Pflichtenheft V2 + +## Lernsimulation „Stadt & Raumplanung“ + +### für Schüler:innen von ca. 10 bis 15 Jahren + +--- + +# 1. Projektziel + +Es soll eine webbasierte Lernsimulation entwickelt werden, in der Schüler:innen eine kleine Siedlung bzw. Stadt schrittweise aufbauen und weiterentwickeln. Das Spiel soll zentrale geographische und gesellschaftliche Zusammenhänge erfahrbar machen, insbesondere: + +* Raumplanung +* Infrastruktur +* Nahversorgung +* Verkehr +* Umwelt +* Lebensqualität +* Zielkonflikte zwischen Wachstum, Versorgung und Nachhaltigkeit + +Die Simulation ist **didaktisch priorisiert**. Sie soll **nicht** den Anspruch haben, eine realistische Stadtplanungssoftware zu sein, sondern ein **verständlich reduziertes System**, das sich spielerisch anfühlt und dennoch fachlich sinnvoll ist. + +--- + +# 2. Didaktische Leitidee + +Die Anwendung soll Schüler:innen nicht nur „bauen lassen“, sondern ihnen zeigen, dass räumliche Entscheidungen Folgen haben. + +Die Lernenden sollen erkennen: + +* dass Gebäude und Infrastruktur voneinander abhängen +* dass gute Versorgung nicht automatisch gute Umwelt bedeutet +* dass Verkehr, Erreichbarkeit und Lebensqualität zusammenhängen +* dass nicht jede kurzfristig gute Entscheidung langfristig sinnvoll ist +* dass ausgewogene Planung oft besser ist als extreme Spezialisierung + +Die Simulation soll also **Systemdenken** fördern. + +--- + +# 3. Zielgruppe + +Primäre Zielgruppe: + +* Schüler:innen im Alter von 10 bis 15 Jahren + +Sekundäre Zielgruppe: + +* Lehrpersonen im Fach Geografie +* ggf. fachübergreifend mit Wirtschaft, Technik, Informatik oder Umweltbildung + +--- + +# 4. Einsatzkontext + +Die Simulation ist in ein bestehendes Lernprogramm eingebettet. Sie ist **eine von mehreren Simulationen**. + +Daraus folgen diese Anforderungen: + +* Die Simulation muss ohne lange Einführung verständlich sein. +* Eine Runde bzw. ein Level muss innerhalb einer Unterrichtsphase bearbeitbar sein. +* Lehrpersonen sollen Ergebnisse mehrerer Schüler:innen vergleichen können. +* Das System soll kompakt genug sein, damit es nicht die gesamte Plattform dominiert. + +--- + +# 5. Spieldauer + +Die Simulation muss konfigurierbar sein, sodass einzelne Szenarien je nach Komplexität typischerweise zwischen **10 und 40 Minuten** dauern. + +## 5.1 Zielwerte + +* Kurzlevel: 10–15 Minuten +* Standardlevel: 20–30 Minuten +* Langlevel: 30–40 Minuten + +## 5.2 Einflussfaktoren auf die Dauer + +Die Spieldauer wird gesteuert durch: + +* Größe des Spielrasters +* Anzahl freigeschalteter Kacheltypen +* Anzahl der Runden +* Anzahl der Ereignisse +* Zahl der gleichzeitig sichtbaren Kennwerte +* Komplexität der Ziele + +--- + +# 6. Grundprinzip des Spiels + +Der Spieler entwickelt eine kleine Ansiedlung Schritt für Schritt weiter. + +Zu Beginn gibt es nur sehr wenige Elemente, zum Beispiel: + +* eine vorhandene Straße +* wenige Häuser +* freie Bauflächen +* einen ersten Bedarf, etwa nach einem Nahversorger + +Im Laufe des Spiels kommen neue Anforderungen hinzu, zum Beispiel: + +* bessere Erreichbarkeit +* mehr Wohnraum +* Schule +* Grünflächen +* Verkehrsprobleme +* sinkende Umweltqualität + +Der Spieler reagiert darauf, indem er **Kacheln platziert**. Diese Platzierungen verändern die Stadt und ihre Kennwerte. + +--- + +# 7. Spielwelt und Darstellung + +## 7.1 Version A als verbindlicher Standard + +Die verbindliche Grundversion wird als **2D-Top-Down-Spiel** umgesetzt. + +Gründe: + +* klarer +* technisch einfacher +* robuster +* didaktisch fokussierter +* schneller umsetzbar + +## 7.2 Isometrie nur optional + +Eine pseudo-isometrische Darstellung ist **nicht Bestandteil der Pflichtversion**, kann aber später als Erweiterung ergänzt werden. + +--- + +# 8. Spielfeld + +## 8.1 Raster + +Das Spielfeld besteht aus einem rechteckigen Raster aus gleich großen Kacheln. + +Empfohlene Größen: + +* klein: 8 × 8 +* mittel: 10 × 10 +* groß: 12 × 12 + +Die Pflichtversion soll mindestens mit 10 × 10 sicher funktionieren. + +## 8.2 Kachelprinzip + +Jede Rasterzelle enthält genau eine Kachel. Eine Kachel kann leer sein oder einen bestimmten Typ haben. + +Beispiele: + +* leer +* Straße +* Kreuzung +* Wohnhaus +* Laden +* Schule +* Park +* Industrie +* Bushaltestelle + +--- + +# 9. Zentrale Begriffe des Systems + +Das gesamte Modell basiert auf drei Ebenen: + +## 9.1 Rahmenbedingungen + +Das sind Ausgangsbedingungen, die der Spieler nicht direkt steuert. + +Beispiele: + +* Startbudget +* Anfangsbevölkerung +* Umweltwert +* Größe des Spielfelds +* Ziele des Levels + +## 9.2 Steuergrößen + +Das sind die Aktionen des Spielers. + +Beispiele: + +* Straße platzieren +* Wohnhaus bauen +* Laden bauen +* Park bauen +* Schule platzieren + +## 9.3 Folgeparameter + +Das sind die Werte, die sich durch die Entscheidungen verändern. + +Beispiele: + +* Verkehr +* Umwelt +* Zufriedenheit +* Wirtschaft +* Erreichbarkeit + +Diese Struktur muss in der Implementierung klar getrennt werden. + +--- + +# 10. Kacheltypen der Pflichtversion + +Die Pflichtversion soll bewusst reduziert bleiben. Es werden nur die nötigsten Kacheltypen verlangt. + +## 10.1 Leerkachel + +* enthält keine Bebauung +* hat keine Funktion +* kann bebaut werden + +## 10.2 Straßenkacheln + +Pflichtversion: + +* Straße gerade horizontal +* Straße gerade vertikal +* Kurve +* T-Kreuzung +* Viererkreuzung +* Sackgasse + +Jede Straßenkachel besitzt definierte Anschlüsse in die vier Himmelsrichtungen: + +* Norden +* Osten +* Süden +* Westen + +## 10.3 Gebäudekacheln + +Pflichtversion: + +* Wohnhaus +* Laden / Nahversorger +* Schule +* Park +* Industrie / Gewerbe +* Bushaltestelle + +## 10.4 Gebäudegröße + +In der Pflichtversion sind **alle Gebäude genau 1×1 Kachel groß**. + +Mehrfeld-Gebäude sind nicht Teil der Pflichtversion. + +--- + +# 11. Kachel-Datenmodell + +Die KI soll intern mit einem strukturierten Tile-Modell arbeiten. + +Beispielstruktur: + +```ts +type TileType = + | "empty" + | "road_straight_h" + | "road_straight_v" + | "road_curve_ne" + | "road_curve_es" + | "road_curve_sw" + | "road_curve_wn" + | "road_t_n" + | "road_t_e" + | "road_t_s" + | "road_t_w" + | "road_cross" + | "road_dead_n" + | "road_dead_e" + | "road_dead_s" + | "road_dead_w" + | "house" + | "shop" + | "school" + | "park" + | "industry" + | "bus_stop"; +``` + +Jede Kachel muss zusätzlich folgende Eigenschaften besitzen: + +```ts +type Tile = { + id: string; + x: number; + y: number; + type: TileType; + connections: { + north: boolean; + east: boolean; + south: boolean; + west: boolean; + }; + buildCost: number; + effects: Partial; +}; +``` + +--- + +# 12. Platzierungslogik + +## 12.1 Didaktische Grundentscheidung + +Falsche Platzierungen sollen **nicht hart blockiert**, sondern grundsätzlich erlaubt werden, sofern sie technisch möglich sind. + +Das ist bewusst so gewählt, weil Fehler didaktisch sichtbar werden sollen. + +## 12.2 Ungültige und problematische Platzierung + +Es gibt drei Kategorien: + +### A. technisch unmöglich + +Diese Aktionen werden nicht zugelassen. + +Beispiele: + +* außerhalb des Rasters platzieren +* auf bereits belegter Kachel platzieren +* ohne Budget bauen + +### B. technisch möglich, aber schlecht + +Diese Aktionen werden erlaubt und haben negative Folgen. + +Beispiele: + +* Straße endet sinnlos +* Laden ohne Erreichbarkeit +* Schule weit weg von Wohnhäusern +* Industrie direkt neben Park oder Wohnhaus + +### C. sinnvoll + +Diese Aktionen verbessern das System. + +--- + +# 13. Anschluss- und Erreichbarkeitslogik + +Dies ist ein zentrales Pflichtmerkmal. + +## 13.1 Straßennetz + +Straßen bilden ein Netz. Zwei benachbarte Straßen sind verbunden, wenn ihre Anschlussrichtungen zusammenpassen. + +Beispiel: + +* Kachel A hat Anschluss nach Osten +* Kachel B rechts daneben hat Anschluss nach Westen +* dann sind beide verbunden + +## 13.2 Erreichbarkeit von Gebäuden + +Gebäude gelten nur dann als „versorgt“ oder „aktiv“, wenn sie an das Straßennetz angeschlossen sind oder direkt an eine Straße angrenzen. + +Für die Pflichtversion reicht folgende Regel: + +* Ein Gebäude ist erreichbar, wenn mindestens eine der vier Nachbarkacheln eine passende Straßenkachel ist. + +Spätere Versionen könnten Netzwerkanalyse verwenden, aber das ist nicht Pflicht. + +--- + +# 14. Spielziel + +Das Spielziel ist **nicht** nur „möglichst viel bauen“. + +Gewonnen wird ein Level, wenn definierte Zielwerte erreicht werden. + +Beispiele: + +* Mindestzufriedenheit +* ausreichende Versorgung +* Umwelt nicht zu schlecht +* kein Verkehrschaos +* Mindestwirtschaftswert + +Ein Level kann also nur bestanden werden, wenn mehrere Zielbereiche zugleich in einem akzeptablen Zustand sind. + +--- + +# 15. Kernparameter der Simulation + +Die Pflichtversion soll mit wenigen, aber aussagekräftigen Parametern arbeiten. + +Alle Werte liegen auf einer Skala von 0 bis 100. + +## 15.1 Wirtschaft + +Wie gut die Stadt ökonomisch funktioniert. + +Beeinflusst durch: + +* Läden +* Industrie +* Versorgung +* Zufriedenheit +* Erreichbarkeit + +## 15.2 Zufriedenheit + +Wie zufrieden die Bevölkerung ist. + +Beeinflusst durch: + +* Erreichbarkeit +* Versorgung +* Parks +* Schule +* geringe Umweltbelastung +* wenig Verkehrsprobleme + +## 15.3 Umwelt + +Qualität der Umwelt. + +Beeinflusst durch: + +* Parks positiv +* Industrie negativ +* hohe Verkehrsbelastung negativ + +## 15.4 Verkehr + +Belastung des Straßennetzes. + +Beeinflusst durch: + +* Anzahl Gebäude +* fehlende Verbindungen +* Industrie +* schlechte Verteilung + +## 15.5 Versorgung + +Wie gut die Bevölkerung Zugang zu wichtigen Angeboten hat. + +Beeinflusst durch: + +* Laden +* Schule +* Bushaltestellen +* Erreichbarkeit + +## 15.6 Erreichbarkeit + +Wie gut wichtige Gebäude miteinander verbunden sind. + +Beeinflusst durch: + +* Straßennetz +* Lage +* Isolation einzelner Kacheln + +--- + +# 16. Simulationsphilosophie + +Die Simulation muss **einfach verständlich**, aber **nicht trivial** sein. + +Sie muss folgende Eigenschaften haben: + +* positive und negative Effekte zugleich +* keine Maßnahme ist immer gut +* extreme Spezialisierung soll Nachteile erzeugen +* mehrere brauchbare Lösungswege sollen möglich sein + +--- + +# 17. Simulationsmodell der Pflichtversion + +Die Pflichtversion darf mit einem diskreten Rundenmodell arbeiten. + +Nach jeder Runde oder nach jeder Bauaktion werden die Kennwerte neu berechnet. + +## 17.1 Vereinfachtes Modell + +Jede Kachel hat Grundeffekte. +Zusätzlich kommen Lage- und Nachbarschaftseffekte hinzu. + +### Beispielhafte Grundeffekte + +| Kachel | Wirtschaft | Zufriedenheit | Umwelt | Verkehr | Versorgung | +| -------------- | ---------: | ------------: | -----: | ------: | ---------: | +| Haus | +2 | +1 | 0 | +1 | 0 | +| Laden | +4 | +2 | -1 | +2 | +5 | +| Schule | +1 | +5 | 0 | +1 | +4 | +| Park | 0 | +4 | +6 | 0 | +1 | +| Industrie | +6 | -3 | -6 | +4 | 0 | +| Bushaltestelle | +1 | +2 | +1 | -1 | +3 | + +Diese Zahlen sind Startwerte und müssen konfigurierbar sein. + +--- + +# 18. Nachbarschaftseffekte + +Neben den Grundeffekten müssen lokale Nachbarschaften berücksichtigt werden. + +## 18.1 Positive Beispiele + +* Park neben Haus: Zufriedenheit +2 +* Laden nahe Wohnhäusern: Versorgung +2 +* Schule nahe Wohnhäusern: Zufriedenheit +2 +* Bushaltestelle bei Laden oder Schule: Erreichbarkeit +2 + +## 18.2 Negative Beispiele + +* Industrie neben Haus: Zufriedenheit -4, Umwelt -3 +* Industrie neben Park: Umwelt -4 +* Sackgassenhäufung: Verkehr +2 +* isolierter Laden ohne Anbindung: Wirtschaft -3, Versorgung -4 + +--- + +# 19. Reichweitenmodell + +Für die Pflichtversion genügt ein einfaches Distanzmodell auf Rasterbasis. + +## 19.1 Empfohlene Reichweiten + +* Laden versorgt Häuser im Umkreis von 3 Kacheln +* Schule wirkt im Umkreis von 4 Kacheln +* Park wirkt im Umkreis von 2 Kacheln +* Bushaltestelle verbessert Erreichbarkeit im Umkreis von 3 Kacheln + +Die Distanz kann zunächst als Manhattan-Distanz berechnet werden. + +--- + +# 20. Rundenablauf + +Ein Level läuft rundenbasiert. + +## 20.1 Pflichtablauf pro Runde + +1. Aktuelle Stadt anzeigen +2. Ereignis oder Bedarf anzeigen +3. Spieler erhält Budget für die Runde oder nutzt vorhandenes Budget +4. Spieler platziert 1 bis n Kacheln +5. Spiel berechnet neue Kennwerte +6. Spiel zeigt Feedback +7. Prüfen auf Sieg / Niederlage / Fortsetzung + +--- + +# 21. Ereignissystem + +Das Spiel soll über Ereignisse oder Meldungen gesteuert werden, damit es nicht bloß ein freies Bauen ist. + +## 21.1 Typen von Ereignissen + +* Bedarfsmeldungen +* Warnungen +* Zielmeldungen +* Zwischenfeedback + +## 21.2 Beispiele + +* „Die Bewohner wünschen sich einen kleinen Laden.“ +* „Die Verkehrsbelastung steigt.“ +* „Die Umweltqualität sinkt.“ +* „Viele Häuser sind schlecht versorgt.“ +* „Ein Park würde die Lebensqualität verbessern.“ + +## 21.3 Funktion + +Ereignisse dienen dazu: + +* die Aufmerksamkeit zu lenken +* den nächsten sinnvollen Schritt vorzuschlagen +* die Komplexität schrittweise aufzubauen + +--- + +# 22. Progression + +Die Simulation muss neue Elemente schrittweise freischalten. + +## 22.1 Didaktische Reihenfolge + +Empfohlene Stufen: + +### Stufe 1 + +* Straße +* Haus +* Laden + +### Stufe 2 + +* Park +* Bushaltestelle + +### Stufe 3 + +* Schule + +### Stufe 4 + +* Industrie + +Diese Progression ist Pflichtbestandteil des Designs, auch wenn einzelne Levels später davon abweichen dürfen. + +--- + +# 23. UI der Pflichtversion + +## 23.1 Hauptaufbau + +Die Benutzeroberfläche besteht mindestens aus vier Bereichen: + +### A. Spielfeld + +Raster mit allen Kacheln + +### B. Werkzeugleiste + +Auswahl der verfügbaren Kacheln + +### C. Statusbereich + +Anzeige der wichtigsten Kennwerte + +### D. Nachrichtenbereich + +Ereignisse, Hinweise, Feedback + +--- + +## 23.2 Interaktion + +Pflichtversion: + +* Kachel in der Werkzeugleiste anklicken +* Zielzelle im Raster anklicken +* Kachel wird platziert, wenn dies technisch zulässig ist +* Kennwerte aktualisieren sich danach + +Optional: + +* Hover-Vorschau +* Kostenanzeige vor Platzierung + +--- + +# 24. Visuelle Sprache + +Die Grafik soll bewusst einfach und klar sein. + +## 24.1 Pflichtversion + +* 2D +* reduzierte Symbole +* gut erkennbare Gebäude +* Straßen müssen sofort lesbar sein + +## 24.2 Stil + +* freundlich +* nicht überladen +* kinder- und jugendgerecht +* kein realistischer Stil notwendig + +## 24.3 Emojis + +Emojis dürfen optional für kleine Animationen oder Objekte verwendet werden, etwa: + +* Fahrzeuge +* Gesichter für Zufriedenheit +* Warnsymbole + +Sie dürfen aber nicht die Kernlesbarkeit des Spielfelds ersetzen. + +--- + +# 25. Balancing-Grundsätze + +Die KI muss das Spiel so programmieren, dass keine triviale Patentlösung entsteht. + +## 25.1 Pflichtregeln + +* Ein Kacheltyp darf nicht allein das Spiel „lösen“. +* Industrie darf Wirtschaft verbessern, aber Umwelt und Zufriedenheit verschlechtern. +* Parks dürfen Umwelt und Zufriedenheit verbessern, aber keine Wirtschaft ersetzen. +* Straßen erhöhen Erreichbarkeit, können aber zu viel Verkehrsfläche erzeugen. +* Eine gute Lösung muss mehrere Ziele gleichzeitig berücksichtigen. + +--- + +# 26. Bewertungslogik + +Die Pflichtversion benötigt ein Mehrzielsystem. + +## 26.1 Primäre Zielwerte + +* Wirtschaft +* Zufriedenheit +* Umwelt +* Versorgung +* Verkehr +* Erreichbarkeit + +## 26.2 Umrechnung in Endbewertung + +Zusätzlich zu den Einzelwerten soll das Spiel einen Gesamtscore berechnen. + +Beispiel: + +```ts +totalScore = + 0.22 * economy + + 0.24 * satisfaction + + 0.18 * environment + + 0.16 * supply + + 0.12 * accessibility + + 0.08 * (100 - traffic); +``` + +Die genaue Gewichtung muss pro Level konfigurierbar sein. + +--- + +# 27. Balance-Index + +Neben dem Gesamtscore soll ein zweiter Wert berechnet werden: + +## 27.1 Balance-Index + +Dieser misst, wie ausgewogen die Lösung ist. + +Ziel: + +* extreme Einseitigkeit sichtbar machen + +Beispiel: + +* sehr hohe Wirtschaft, aber sehr schlechte Umwelt und Zufriedenheit → niedriger Balance-Index + +Der Balance-Index kann über die Streuung der Teilwerte berechnet werden. + +--- + +# 28. Sieg- und Niederlagenbedingungen + +## 28.1 Sieg + +Ein Level gilt als bestanden, wenn: + +* Mindestscore erreicht ist +* keine harten Negativbedingungen verletzt sind +* das Rundenziel erreicht wurde + +## 28.2 Niederlage + +Ein Level gilt als verloren, wenn z. B.: + +* Budget aufgebraucht und Ziel klar verfehlt +* Verkehr zu hoch +* Zufriedenheit zu niedrig +* Versorgung zu niedrig +* Umwelt zu niedrig + +Konkrete Schwellwerte müssen je Level definiert werden. + +--- + +# 29. Lehreransicht + +Die Simulation ist in ein Lernprogramm eingebettet. Daher muss sie auswertbar sein. + +## 29.1 Pflichtdaten pro Spiel + +Es müssen mindestens diese Daten gespeichert oder an das übergeordnete System zurückgegeben werden: + +* Schüler-ID oder Sitzungs-ID +* Level-ID +* Startzeit +* Endzeit +* Gesamtdauer +* Endscore +* Balance-Index +* Endwerte aller Hauptparameter +* Anzahl gesetzter Kacheln pro Typ +* Anzahl problematischer Platzierungen +* Sieg / Niederlage + +## 29.2 Optionale Lehrpersoneninformationen + +Diese Daten können zusätzlich angezeigt werden: + +* Strategieprofil +* Schwerpunkt der Planung +* einseitige oder ausgewogene Lösung +* häufigste Fehler +* Zahl unverbundener Straßen +* Zahl schlecht versorgter Häuser + +Diese Punkte sind als optionale Erweiterung zu behandeln, aber die Datenbasis soll vorbereitet werden. + +--- + +# 30. Auswertungsmodell für Lehrpersonen + +Die Lehrperson soll nicht nur sehen, wer „gewonnen“ hat, sondern auch, wie. + +## 30.1 Beispielhafte Ergebnisansicht + +| Name | Score | Balance | Wirtschaft | Umwelt | Zufriedenheit | Kommentar | +| ---- | ----: | ------: | ---------: | -----: | ------------: | ---------------------------------------- | +| A | 78 | 74 | 70 | 72 | 80 | ausgewogene Lösung | +| B | 81 | 43 | 92 | 28 | 40 | wirtschaftlich stark, ökologisch schwach | + +## 30.2 Automatische Kommentare + +Optional generierte Kommentare: + +* „Die Stadt ist wirtschaftlich stark, aber Umwelt und Zufriedenheit leiden.“ +* „Die Lösung ist gut ausbalanciert.“ +* „Viele wichtige Gebäude sind schlecht erreichbar.“ +* „Es wurden zu viele isolierte Straßen gebaut.“ + +--- + +# 31. Technische Architektur + +Die KI soll die Anwendung modular aufbauen. + +## 31.1 Empfohlene Hauptmodule + +* Grid Engine +* Tile Placement Logic +* Road Connectivity Logic +* Simulation Engine +* Event Engine +* Scoring Engine +* UI Renderer +* Session / Save Adapter +* Teacher Data Export + +## 31.2 Technologie + +Pflichtversion: + +* HTML +* CSS +* JavaScript oder TypeScript + +Empfehlung: + +* TypeScript + +Rendering: + +* Canvas oder DOM/SVG + +Für Version A ist beides möglich. +Wenn die KI sauber strukturieren kann, ist ein rasterbasiertes Canvas-Rendering sinnvoll. DOM/SVG ist ebenfalls akzeptabel, wenn die Implementierung einfacher und stabiler wird. + +--- + +# 32. Persistenz + +Die Pflichtversion braucht keine komplexe Datenbank im Spiel selbst. + +Es muss aber möglich sein: + +* Spieldaten pro Sitzung zu speichern +* Ergebnisse an ein übergeordnetes Lernsystem zu übergeben + +Falls keine Plattformanbindung vorhanden ist, genügt vorerst: + +* lokaler Speicher +* JSON-Export +* Callback-Schnittstelle + +--- + +# 33. Datenformate + +Die KI soll Leveldaten nicht hart im Code verankern, sondern über Konfigurationsobjekte steuern. + +## 33.1 Beispielstruktur für ein Level + +```ts +type LevelConfig = { + id: string; + title: string; + description: string; + gridWidth: number; + gridHeight: number; + rounds: number; + startBudget: number; + unlockedTileTypes: TileType[]; + targetWeights: { + economy: number; + satisfaction: number; + environment: number; + supply: number; + accessibility: number; + traffic: number; + }; + winThreshold: number; + loseThresholds: { + minEnvironment?: number; + minSatisfaction?: number; + minSupply?: number; + maxTraffic?: number; + }; + initialTiles: Array<{ + x: number; + y: number; + type: TileType; + }>; + scriptedEvents: Array<{ + round: number; + message: string; + hint?: string; + }>; +}; +``` + +--- + +# 34. Beispiellevel der Pflichtversion + +## 34.1 Level 1: Kleine Ansiedlung + +Ausgangslage: + +* wenige Häuser +* eine Straße +* viel freie Fläche + +Ziel: + +* Nahversorgung schaffen +* Zufriedenheit erhöhen +* kein Verkehrschaos erzeugen + +Freigeschaltet: + +* Straße +* Haus +* Laden + +Dauer: + +* 10–15 Minuten + +## 34.2 Level 2: Wachsende Gemeinde + +Neu: + +* Park +* Bushaltestelle + +Ziel: + +* Versorgung und Lebensqualität verbessern + +Dauer: + +* 15–25 Minuten + +## 34.3 Level 3: Schule für den Ort + +Neu: + +* Schule + +Ziel: + +* Erreichbarkeit und Zufriedenheit + +Dauer: + +* 20–30 Minuten + +## 34.4 Level 4: Industrie oder Umwelt? + +Neu: + +* Industrie + +Ziel: + +* Wirtschaft stärken, aber Umwelt und Zufriedenheit nicht ruinieren + +Dauer: + +* 25–40 Minuten + +--- + +# 35. Testanforderungen + +Die KI soll nicht nur das Spiel bauen, sondern auch ein kleines internes Testsystem vorsehen. + +## 35.1 Pflicht-Checks + +* Straßenanschlüsse korrekt? +* Gebäude erreichbar? +* Score-Berechnung korrekt? +* Level gewinnbar? +* Level nicht mit nur einer Maßnahme trivial lösbar? + +## 35.2 Balancing-Checks + +Die Implementierung soll so vorbereitet sein, dass automatisierte Tests mehrere Baukonstellationen durchspielen können. + +Mindestens soll prüfbar sein: + +* ob ein einzelner Kacheltyp zu stark ist +* ob bestimmte Levels ungewinnbar sind +* ob Extremstrategien das Spiel kaputt machen + +--- + +# 36. Nicht-Ziele der Pflichtversion + +Diese Punkte sind ausdrücklich **nicht** Teil der Pflichtversion: + +* echtes 3D +* frei drehbare Kamera +* komplexe Energieversorgung +* Wasserleitungen +* komplexe Einwohner-Simulation pro Person +* Echtzeitverkehr mit Pfadsuche für hunderte Einheiten +* mehrfeldrige Großgebäude +* Netzwerk-Multiplayer + +Diese Themen können später ergänzt werden, sollen die Pflichtversion aber nicht verkomplizieren. + +--- + +# 37. Abnahmekriterien + +Das Projekt gilt als erfolgreich umgesetzt, wenn folgende Punkte erfüllt sind: + +## 37.1 Fachlich + +* Zielkonflikte sind erkennbar +* Planung hat sichtbare Folgen +* mehrere Lösungswege sind möglich + +## 37.2 Didaktisch + +* Schüler:innen verstehen die Grundidee ohne lange Erklärung +* das Spiel ist in 10–40 Minuten spielbar +* die Lehrperson kann Ergebnisse vergleichen + +## 37.3 Technisch + +* Raster und Platzierung funktionieren stabil +* Kennwerte werden korrekt aktualisiert +* Leveldaten sind konfigurierbar +* Ergebnisse können gespeichert oder übergeben werden + +## 37.4 Spielerisch + +* das Spiel motiviert zum Ausprobieren +* Fehlentscheidungen sind sichtbar +* keine triviale „immer gleiche“ Gewinnstrategie dominiert + +--- + +# 38. Konkrete Arbeitsanweisung an die KI + +Die KI soll auf Basis dieses Pflichtenhefts eine erste lauffähige Version entwickeln mit folgenden Prioritäten: + +## Priorität 1 + +* stabiles Raster +* Kachelplatzierung +* Straßennetzlogik +* Grundsimulation +* 1 spielbares Level + +## Priorität 2 + +* mehrere Level +* Ereignissystem +* Score und Balance-Index +* Ergebnisansicht + +## Priorität 3 + +* Lehreransicht +* Export / Schnittstelle +* Balancing-Verbesserungen +* grafische Verfeinerung + +--- + +# 39. Umsetzungshinweis + +Die KI soll **nicht** versuchen, SimCity nachzubauen. +Die KI soll eine **kleine, klare, didaktisch reduzierte Lernsimulation** entwickeln, die von der grundsätzlichen Logik klassischer Aufbauspiele inspiriert ist, aber wesentlich einfacher, transparenter und schulgeeignet bleibt. + +--- + +# 40. Empfohlener nächster Schritt + +Auf Basis dieses Pflichtenhefts ist der sinnvollste nächste Schritt: + +1. daraus eine **Programmier-Prompt-Version** für eine KI zu machen + oder +2. direkt eine **erste MVP-Version** in TypeScript/HTML zu erzeugen + +Wenn du willst, mache ich dir als Nächstes die **ultrapraktische Prompt-Fassung für Cursor / GPT / Claude Code**, damit eine KI sofort mit der Implementierung beginnen kann. diff --git a/App/.env.example b/App/.env.example new file mode 100644 index 0000000..da8d1fb --- /dev/null +++ b/App/.env.example @@ -0,0 +1,6 @@ +# GeoGraSim — Environment Configuration +DB_HOST=localhost +DB_NAME=geograsim +DB_USER=root +DB_PASS= +DB_PORT=3306 diff --git a/App/.htaccess b/App/.htaccess new file mode 100644 index 0000000..6adf55c --- /dev/null +++ b/App/.htaccess @@ -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] diff --git a/App/dashboard.html b/App/dashboard.html new file mode 100644 index 0000000..b1cc41b --- /dev/null +++ b/App/dashboard.html @@ -0,0 +1,297 @@ + + + + + + GeoGraSim — Lehrkräfte-Dashboard + + + + + + +
⚠️ Demo-Mockup — alle Daten sind erfunden, kein Backend angeschlossen
+ + + +
+ + + +
+ + + + +
+
+

Aktive Schüler*innen

+
18 / 24
+
↗ +3 seit gestern
+
+
+

Abgeschlossene Simulationen

+
142
+
↗ 87% Erfolgsquote
+
+
+

Durchschn. Lernzeit

+
23 min
+
pro Simulation
+
+
+

Identifizierte Fehlkonzepte

+
5
+
⚠ Aufmerksamkeit nötig
+
+
+ + +
+ + +
+

🔴 Live: Treibhauseffekt-Simulator

+

12 von 24 Schüler*innen bearbeiten gerade die Simulation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Schüler*inPhaseFortschrittZeit
👤 Anna B.
Observe
14:32
👤 Lukas F.
Reflect
18:45
👤 Sara M.
Predict
05:12
👤 Tim H.
Pausiert
09:20
👤 Emma K.
Reflect
21:08
+
+ + +
+

💡 Erkannte Fehlkonzepte

+ +
+ Ozonloch ≠ Treibhauseffekt
+ 7 Schüler*innen haben in ihrer Vorhersage erwähnt, dass das "Ozonloch" für die Erwärmung verantwortlich sei. + → Empfehlung: Im Plenum klarstellen. +
+ +
+ Lineare statt logarithmische Beziehung
+ 5 Schüler*innen haben "doppelt so viel CO₂ = doppelt so warm" angenommen. + → Empfehlung: Klimasensitivität gemeinsam besprechen. +
+ +
+ Albedo nicht verstanden
+ 3 Schüler*innen haben Albedo nicht verändert. → Optionale Vertiefung anbieten. +
+ +

🎯 Lernziele-Erreichung (Klasse)

+
+
Treibhauseffekt verstehen83%
+
+ +
CO₂-Temperatur-Zusammenhang71%
+
+ +
Abgrenzung zum Ozonloch54%
+
+
+
+
+ + +
+

⚙️ Module für Klasse 1A freigeschaltet

+
+
+
🌡️
+
+
Treibhauseffekt-Simulator
+

1. Klasse · 20 min · Schwierigkeit: Standard

+
+
+
+
+
🌋
+
+
Erdbeben-Simulator
+

1. Klasse · 25 min · Schwierigkeit: Standard

+
+
+
+
+
+
+
Energiemix-Simulator
+

2. Klasse · 25 min · Erst nächstes Jahr

+
+
+
+
+
🏗️
+
+
Raumplanung Gemeinde
+

3. Klasse · 45 min · Erst in 2 Jahren

+
+
+
+
+
+ + +
+

🗳️ Live-Abstimmung starten

+

Stelle deiner Klasse eine Frage. Das Ergebnis fließt direkt in die laufende Simulation ein.

+
+ + +
+
+ +
+
+ + + + diff --git a/App/energiemix.html b/App/energiemix.html new file mode 100644 index 0000000..2d81ea6 --- /dev/null +++ b/App/energiemix.html @@ -0,0 +1,239 @@ + + + + + + GeoGraSim — Energiewende-Planer*in + + + + + + + + +
+ + + +
+
+
+
+

+

+
+ +
+
+
+ +
+ + + + +
+ +
+
+
🌱 Erneuerbar-Anteil
+ +
+
+
🌫 CO₂-Ausstoß
+ +
+
+
⚡ Erzeugung vs. Bedarf
+ +
+
+
💰 Budget
+ +
+
+
+ + + +
+ +
+ + + + diff --git a/App/erdbeben.html b/App/erdbeben.html new file mode 100644 index 0000000..9ef0fad --- /dev/null +++ b/App/erdbeben.html @@ -0,0 +1,230 @@ + + + + + + GeoGraSim — Stadtplaner*in in Erdbebenregion + + + + + + + + +
+ + + +
+
+
+
+

+

+
+ +
+
+
+ +
+ + + + +
+ +
+
+
👥 Bevölkerung
+ +
+
+
🏠 Wohnraum
+ +
+
+
💰 Budget
+ +
+
+
🕯️ Opfer (kumulativ)
+ +
+
+
+ + + +
+ +
+ + + + diff --git a/App/fluss.html b/App/fluss.html new file mode 100644 index 0000000..c595605 --- /dev/null +++ b/App/fluss.html @@ -0,0 +1,859 @@ + + + + + + GeoGraSim — Flussmanagement + + + + + + +
+
+
Zustand
+
Verlauf
+
Ereignisse
+
+
+
+
💰 30
+ +0/J + +
📅 0
+
+
+
+
+
+
Werkzeug
+
Info
Maus über die Karte bewegen.
+
+
+
+
+
🏞️
+

Flussmanagement

+

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.

+ +
+
+ + + + + diff --git a/App/game-3d.html b/App/game-3d.html new file mode 100644 index 0000000..7267905 --- /dev/null +++ b/App/game-3d.html @@ -0,0 +1,1239 @@ + + + + + + GeoGraSim — Klimawächter 3D + + + + + + + +
+ + + + + + + + + + + + + + +
+
+
+
+ 🌫 CO₂ ppm i + +
+ +
+
+
+ 🌡 Temperatur + °C i + Paris i + + +
+ +
+
+
+ 💰 Budget Mio € i + +
+ +
+
+
+ 🌊 Meer cm i + +
+ +
+
+
+ + +
+
+

+

+
+ +
+
+ +
+
+
+
+ + +
+ 🏗 +
+
Maßnahme platzieren
+
Klick = bauen · weitere Klicks = Mehrfach-Bau · ESC oder Rechtsklick = beenden · ← → drehen
+
+
+ + + + + + + diff --git a/App/game.html b/App/game.html new file mode 100644 index 0000000..9bacac4 --- /dev/null +++ b/App/game.html @@ -0,0 +1,460 @@ + + + + + + GeoGraSim — Klimawächter + + + + + + + + +
+ + + + + +
+
+
+
+

+

+
+ +
+
+
+ +
+ + + + +
+ +
+
+
🌫 CO₂ über Zeit
+ +
+
+
🌡 Temperatur über Zeit
+ +
+
+
💰 Budget über Zeit
+ +
+
+
🌊 Meeresspiegel über Zeit
+ +
+
+
+ + + + +
+ + +
+ + + + diff --git a/App/index.html b/App/index.html new file mode 100644 index 0000000..1a8ca57 --- /dev/null +++ b/App/index.html @@ -0,0 +1,574 @@ + + + + + + GeoGraSim — Geografie erleben + + + + + + + + + + +
+ +
+
+

Geografie
erleben.
Nicht nur lesen.

+

Interaktive Simulationen für den GW-Unterricht. Klimawandel verstehen. Erdbeben simulieren. Städte planen. Zukunft gestalten.

+ +
+
+
+ +
+

🌡️ Treibhauseffekt-Simulator

+

CO₂ verändern → Temperatur beobachten. 20 min.

+
+
+
⚡ 24 Module
+
📊 330 Quellen
+
+
+
+ + +
+ 🌡️ Treibhauseffekt🌋 Plattentektonik⚡ Energiemix🏗️ Raumplanung🌍 Planetary Boundaries🤝 Klimakonferenz📊 Bevölkerungsdynamik💧 Wasserkreislauf🏙️ Urbanisierung♻️ Nachhaltigkeit🗺️ Geomedien🇦🇹 Lehrplan 2023 + 🌡️ Treibhauseffekt🌋 Plattentektonik⚡ Energiemix🏗️ Raumplanung🌍 Planetary Boundaries🤝 Klimakonferenz📊 Bevölkerungsdynamik💧 Wasserkreislauf🏙️ Urbanisierung♻️ Nachhaltigkeit🗺️ Geomedien🇦🇹 Lehrplan 2023 +
+ + +
+ + +
+
+
+
24
Simulationen
+
4
Schulstufen
+
~25'
pro Einsatz
+
330
Forschungsquellen
+
+
+
+ + +
+
+
+ Simulationen +

Lernziele, die man
erleben kann.

+

Jedes Modul: lehrplankonform, evidenzbasiert, 20–30 Minuten.

+
+
+ + +
+
+

🌡️ Klimawächter (2D)

+

Inselstaat bis 2100 durch die Klimakrise führen. 75 Jahre, echte Klimaphysik.

+
1. Klasse⏱ 20 min
+
+
+ + +
+
+

🌊 Klimawächter (3D) NEU

+

Dieselbe Insel — jetzt in echtem 3D. Steigender Meeresspiegel wird sichtbar.

+
1. Klasse⏱ 20 min
+
+
+ + +
+
+

🌋 Stadtplaner*in in Erdbebenregion

+

50 Jahre Stadt in Erdbebenzone bauen. Naturgefahr ≠ Naturkatastrophe — Bauqualität entscheidet.

+
1. Klasse⏱ 25 min
+
+
+ + +
+
+

⚡ Energiewende-Planer*in

+

Bis 2050 deine Region auf 80 % Erneuerbare bringen — ohne Blackout, ohne Pleite.

+
2. Klasse⏱ 25 min
+
+
+ + +
+
+

👕 Lieferketten-Planer*in NEU

+

Was kostet dein T-Shirt wirklich? Bestelle aus aller Welt — und finde die Balance aus Preis, CO₂ und fairen Bedingungen.

+
2. Klasse⏱ 15 min
+
+
+ + +
+
+

🌴 Forscher*in im Regenwald NEU

+

Fahre mit dem Boot den Fluss hoch. Entdecke 15 Tiere, Pflanzen und Menschen — vom Delta bis zum Nebelwald.

+
1. Klasse⏱ 10 min
+
+
+ + +
🏞️
+
+

🏞️ Flussmanagement NEU

+

Steuere ein Flusssystem: Deiche, Renaturierung, Bewässerung. Finde die Balance zwischen Schutz, Ökologie und Wirtschaft.

+
1.–2. Klasse⏱ 10–35 min5 Levels
+
+
+ +
+
+
+

🏗️ Raumplanung — Gemeinde

+

Wohngebiete, Gewerbe und Parks platzieren. Nutzungskonflikte entstehen von selbst.

+
3. Klasse⏱ 45 min
+
+
+ +
+
+
+

📊 Bevölkerungsdynamik

+

Geburtenrate, Sterberate, Migration — Bevölkerungspyramiden über 100 Jahre.

+
4. Klasse⏱ 25 min
+
+
+ +
+
+
+

🌍 Planetary Boundaries

+

Neun planetare Grenzen in Echtzeit. Überschreitest du die Kipppunkte?

+
4. Klasse⏱ 30 min
+
+
+ +
+

+ 18 weitere Module für alle Kompetenzbereiche

+
+
+ + +
+
+
+ Features +

Für Schüler*innen
und Lehrkräfte.

+
+
+
📊

Dashboard

Echtzeit-Überblick, Ergebnisvergleich, Fehlkonzept-Erkennung, PDF-Export.

+

Inklusion

Nachteilsausgleich, vereinfachte Sprache, Vorlesefunktion, flexible Zeiten.

+
🗳️

Live-Abstimmung

Polls starten — Ergebnis fließt in die Simulation ein.

+
🎯

Lehrplan 2023

Kompetenzziele und Basiskonzepte referenziert. DACH-kompatibel.

+
⚙️

Steuerbar

Module freischalten, Schwierigkeit und Zeitlimits pro Schüler*in anpassen.

+
📈

Assessment

Predict-Observe-Explain eingebaut. Prozess- und Ergebnisdaten.

+
+
+
+ + +
+
+
+ Vier Jahre +

Ein Lehrplan.
24 Simulationen.

+

Spiralcurricular — jedes Jahr hat eigene Themen und Module.

+
+
+

1. Klasse

Leben und Wirtschaften
8 Simulationen · 2 WoStd.
Klima · Naturgefahren · Geomedien
+

2. Klasse

Nachhaltigkeit
4 Simulationen · 1 WoStd.
Energie · Wasser · Lieferketten
+

3. Klasse

Österreich
6 Simulationen · 2 WoStd.
Standort · Raumplanung · Wirtschaft
+

4. Klasse

Globalisierte Welt
6 Simulationen · 2 WoStd.
Boundaries · Klima · EU
+
+
+
+ + +
+

Bereit, Geografie
zu erleben?

+

Evidenzbasiert · Lehrplan-kompatibel · Inklusiv

+ +
+ +
+

GeoGraSim — Schroffenegger & Mößlang, 2026 · 330 Quellen · Forschung · Didaktik

+
+ + + + diff --git a/App/index.php b/App/index.php new file mode 100644 index 0000000..c5b848f --- /dev/null +++ b/App/index.php @@ -0,0 +1,56 @@ +

404 — Seite nicht gefunden

Zurück zur Startseite

'; diff --git a/App/lieferketten.html b/App/lieferketten.html new file mode 100644 index 0000000..2da4915 --- /dev/null +++ b/App/lieferketten.html @@ -0,0 +1,545 @@ + + + + + + GeoGraSim — Lieferketten-Planer*in + + + + + + +
+ + + + + + + + + + + + + + +
+
+

+

+
+ +
+
+ +
+
+
+ + + + diff --git a/App/package.json b/App/package.json new file mode 100644 index 0000000..4fa2852 --- /dev/null +++ b/App/package.json @@ -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" + } +} diff --git a/App/pages/dashboard.php b/App/pages/dashboard.php new file mode 100644 index 0000000..6783c27 --- /dev/null +++ b/App/pages/dashboard.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/energiemix.php b/App/pages/energiemix.php new file mode 100644 index 0000000..02741d0 --- /dev/null +++ b/App/pages/energiemix.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/erdbeben.php b/App/pages/erdbeben.php new file mode 100644 index 0000000..bac8567 --- /dev/null +++ b/App/pages/erdbeben.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/fluss.php b/App/pages/fluss.php new file mode 100644 index 0000000..d1f5b2f --- /dev/null +++ b/App/pages/fluss.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/game-3d.php b/App/pages/game-3d.php new file mode 100644 index 0000000..cf7404a --- /dev/null +++ b/App/pages/game-3d.php @@ -0,0 +1,18 @@ +' . "\n" + . 'window.__GGS__ = ' . json_encode([ + 'sessionId' => Session::studentId(), + 'teacherId' => Session::teacherId(), + 'baseUrl' => BASE_URL, + 'basePath' => BASE_PATH, + ], JSON_UNESCAPED_UNICODE) . ";\n" + . ''; + +$html = str_replace('', $sessionScript . "\n", $html); +echo $html; diff --git a/App/pages/game.php b/App/pages/game.php new file mode 100644 index 0000000..2329561 --- /dev/null +++ b/App/pages/game.php @@ -0,0 +1,18 @@ +' . "\n" + . 'window.__GGS__ = ' . json_encode([ + 'sessionId' => Session::studentId(), + 'teacherId' => Session::teacherId(), + 'baseUrl' => BASE_URL, + 'basePath' => BASE_PATH, + ], JSON_UNESCAPED_UNICODE) . ";\n" + . ''; + +$html = str_replace('', $sessionScript . "\n", $html); +echo $html; diff --git a/App/pages/index.php b/App/pages/index.php new file mode 100644 index 0000000..ab57f5c --- /dev/null +++ b/App/pages/index.php @@ -0,0 +1,21 @@ + injizieren +$sessionScript = ''; + +$html = str_replace('', $sessionScript . "\n", $html); + +echo $html; diff --git a/App/pages/lieferketten.php b/App/pages/lieferketten.php new file mode 100644 index 0000000..c5f826d --- /dev/null +++ b/App/pages/lieferketten.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/regenwald.php b/App/pages/regenwald.php new file mode 100644 index 0000000..7fab607 --- /dev/null +++ b/App/pages/regenwald.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/sim.php b/App/pages/sim.php new file mode 100644 index 0000000..01ac506 --- /dev/null +++ b/App/pages/sim.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/pages/stilauswahl.php b/App/pages/stilauswahl.php new file mode 100644 index 0000000..ad00226 --- /dev/null +++ b/App/pages/stilauswahl.php @@ -0,0 +1,5 @@ +window.__GGS__ = ' . json_encode(['sessionId' => Session::studentId(), 'teacherId' => Session::teacherId(), 'baseUrl' => BASE_URL, 'basePath' => BASE_PATH], JSON_UNESCAPED_UNICODE) . ';'; +echo str_replace('', $ctx . "\n", $html); diff --git a/App/php/api/assessment.php b/App/php/api/assessment.php new file mode 100644 index 0000000..65c0a86 --- /dev/null +++ b/App/php/api/assessment.php @@ -0,0 +1,41 @@ +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()]); diff --git a/App/php/api/dashboard.php b/App/php/api/dashboard.php new file mode 100644 index 0000000..d6e92bc --- /dev/null +++ b/App/php/api/dashboard.php @@ -0,0 +1,68 @@ +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, +]); diff --git a/App/php/api/saves.php b/App/php/api/saves.php new file mode 100644 index 0000000..1beabaa --- /dev/null +++ b/App/php/api/saves.php @@ -0,0 +1,44 @@ + 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); diff --git a/App/php/api/sessions.php b/App/php/api/sessions.php new file mode 100644 index 0000000..8fe4c0d --- /dev/null +++ b/App/php/api/sessions.php @@ -0,0 +1,69 @@ +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'); diff --git a/App/php/config/app.php b/App/php/config/app.php new file mode 100644 index 0000000..ade4a63 --- /dev/null +++ b/App/php/config/app.php @@ -0,0 +1,38 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return $pdo; +} diff --git a/App/php/lib/Database.php b/App/php/lib/Database.php new file mode 100644 index 0000000..f1750af --- /dev/null +++ b/App/php/lib/Database.php @@ -0,0 +1,47 @@ +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(); + } +} diff --git a/App/php/lib/Response.php b/App/php/lib/Response.php new file mode 100644 index 0000000..e5362e2 --- /dev/null +++ b/App/php/lib/Response.php @@ -0,0 +1,21 @@ + $message], $status); + } + + public static function ok($data = null): void { + self::json($data ?? ['ok' => true]); + } +} diff --git a/App/php/lib/Session.php b/App/php/lib/Session.php new file mode 100644 index 0000000..fad9be3 --- /dev/null +++ b/App/php/lib/Session.php @@ -0,0 +1,88 @@ +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 . '/']); + } +} diff --git a/App/php/templates/_scripts.php b/App/php/templates/_scripts.php new file mode 100644 index 0000000..b60664a --- /dev/null +++ b/App/php/templates/_scripts.php @@ -0,0 +1,43 @@ +' . "\n"; + } + // Preload imports + foreach ($chunk['imports'] ?? [] as $importKey) { + $imp = $manifest[$importKey] ?? null; + if ($imp) { + echo '' . "\n"; + } + } + // Main entry + echo '' . "\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 '' . "\n"; +} diff --git a/App/regenwald.html b/App/regenwald.html new file mode 100644 index 0000000..00e760c --- /dev/null +++ b/App/regenwald.html @@ -0,0 +1,518 @@ + + + + + + GeoGraSim — Forscher*in im Regenwald + + + + + + + + + + +
+

📔 Sammelheft

+
+
0 von 15 entdeckt
+
+
+ + +
+ 🏝 + Flussmündung +
+ + +
+ flussaufwärts · flussabwärts · Klick auf ein Tier zum Entdecken +
+ + +
+
+
+
+ +
+ + +
🛶
+ + +
+ +
+ + + + diff --git a/App/schema.sql b/App/schema.sql new file mode 100644 index 0000000..914bde3 --- /dev/null +++ b/App/schema.sql @@ -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'); diff --git a/App/sim.html b/App/sim.html new file mode 100644 index 0000000..d03f051 --- /dev/null +++ b/App/sim.html @@ -0,0 +1,288 @@ + + + + + + GeoGraSim — Simulation + + + + + + + + +
+
+

+
+ + + +
+
+ +
+
📖 Intro
+
🤔 Vorhersage
+
🔬 Simulation
+
👁️ Beobachtung
+
💭 Reflexion
+
+ +
+
+
+ + + + + + +
+ +
+
+

⚙️ Parameter

+
+
+ +
+

🎯 Lernziele

+
    +
    + + +
    +
    +
    + + + + diff --git a/App/src/core/education-levels.ts b/App/src/core/education-levels.ts new file mode 100644 index 0000000..29e2f69 --- /dev/null +++ b/App/src/core/education-levels.ts @@ -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 +} diff --git a/App/src/core/game-engine.ts b/App/src/core/game-engine.ts new file mode 100644 index 0000000..5b1ba65 --- /dev/null +++ b/App/src/core/game-engine.ts @@ -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 +} + +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 + variables: Record + 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 = new Map() + protected variables: Record = {} + protected events: GameEvent[] = [] + protected timeline: TimelineEntry[] = [] + protected goals: GoalDef[] = [] + protected unlockedFeatures: Set = 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 & { 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)) { + 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 { + return {} + } + + /** Override in Subklasse, um zusätzliche interne Felder wiederherzustellen. */ + protected deserializeSubclass(_data: Record): 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 = {} + 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 + } +} diff --git a/App/src/core/persistence.ts b/App/src/core/persistence.ts new file mode 100644 index 0000000..379a98d --- /dev/null +++ b/App/src/core/persistence.ts @@ -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 { + // 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 { + 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): Promise { + 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 | 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> { + 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, +} diff --git a/App/src/core/router.ts b/App/src/core/router.ts new file mode 100644 index 0000000..50e970f --- /dev/null +++ b/App/src/core/router.ts @@ -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) => 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 = {} + 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 + } +} diff --git a/App/src/core/simulation.ts b/App/src/core/simulation.ts new file mode 100644 index 0000000..2236fd6 --- /dev/null +++ b/App/src/core/simulation.ts @@ -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 + predictions: Record + results: Record + reflections: string[] +} + +export interface AssessmentData { + processLog: Array<{ + timestamp: number + action: string + variable?: string + oldValue?: number + newValue?: number + }> + predictions: Record + results: Record + 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 + + /** Reagiere auf Variablenänderung */ + protected abstract onVariableChange(name: string, value: number): void + + /** Gib die initialen Variablen und ihre Bereiche zurück */ + abstract getVariableRanges(): Record + + // --- 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) + } +} diff --git a/App/src/main.ts b/App/src/main.ts new file mode 100644 index 0000000..9f84d5a --- /dev/null +++ b/App/src/main.ts @@ -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') diff --git a/App/src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts b/App/src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts new file mode 100644 index 0000000..128311e --- /dev/null +++ b/App/src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts @@ -0,0 +1,2557 @@ +/** + * Klimawächter 3D — Three.js Renderer + * + * Vollbild-3D-Ansicht der Insel-Szene. Verwendet dieselbe KlimawaechterGame- + * Logik wie die 2D-Version, rendert aber als interaktive 3D-Welt mit + * Drohnen-Kamera, Maus-Steuerung und sichtbarem Meeresspiegel-Anstieg. + * + * Designprinzipien: + * - Flat-Shaded Skandi-Look, keine Reflexionen/PBR + * - Insel ist absichtlich sehr flach, damit Meeresspiegelanstieg sichtbar wird + * - Drohnenflug: kontinuierliche, langsame Kreisbewegung um die Insel + * - Maus-Drag rotiert manuell, Scrollrad zoomt, Buttons über Methoden + * - Deiche/Mauern werden ans Ufer gesetzt, andere Maßnahmen ins Inselinnere + * - Pre-existierende Dorfhäuser repräsentieren die Startbevölkerung + */ + +import * as THREE from 'three' +import type { KlimawaechterGame } from '../sim-05-treibhaus/game' + +interface Placed { + ownerId: string + mesh: THREE.Object3D + typeId: string +} + +/** + * Einzelne kleine Rauchwolke über einem Haus. + * Steigt auf, wird vom Wind etwas zur Seite gedrückt, löst sich auf + * und spawnt dann wieder unten am Schornstein. + */ +interface SmokePuff { + mesh: THREE.Mesh + lifetime: number // Lebensdauer in Sekunden + age: number // aktuelles Alter + speedY: number // wie schnell es steigt + windAmp: number // individuelle X-Drift-Amplitude + windPhase: number // individuelles Timing + baseRadius: number // Puff-Größe +} + +/** + * Generischer FX-Effekt (Totenkopf-Sprite, Flucht-Boot, …) + * Der Renderer hält eine Liste davon und ruft update() pro Frame auf. + * Gibt update() false zurück, wird der Effekt aus der Liste entfernt. + */ +interface FxEffect { + root: THREE.Object3D + lifetime: number + age: number + update: (dt: number, effect: FxEffect) => boolean +} + +// === Insel-Geometrie-Konstanten === +// Die Insel ist ein LÄNGLICHES Terrain, das vom Meer sanft ansteigt. +// Am hinteren Ende erhebt sich ein Vulkan, der AUS DEM TERRAIN wächst (kein +// separater Aufsatz mehr). +const ISLAND_HALF_WIDTH = 13 // Breite (X-Richtung, ± davon) +const ISLAND_HALF_LENGTH = 17 // Länge (Z-Richtung, ± davon) +const ISLAND_HIGH_EDGE_Y = 1.9 // Grundhöhe am hinteren Rand (Vulkanseite) +const ISLAND_LOW_EDGE_Y = -0.25 // Vorderkante unter Wasser +const WATER_Y_BASE = 0.0 +const MAX_SEA_RISE_UNITS = 1.6 // 200 cm sealevel → komplett über Low-Edge + +const INNER_RADIUS_MAX = 6.5 // Maßnahmen-Bereich grob + +// Vulkan — wird als Gauss-Erhebung ins Terrain eingebacken, +// darüber sitzt eine Kuppel als Gipfel (statt eines Cylinders). +const VOLCANO_X = -3.0 +const VOLCANO_Z = -11.0 +const VOLCANO_BASE_RADIUS = 5.5 // Gauss-Sigma für die Terrain-Erhebung +const VOLCANO_BUMP_HEIGHT = 3.6 // Gauss-Höhe (Terrain-Komponente) +// Kuppel als Gipfel (sitzt direkt auf der Terrain-Erhebung) +// +30 % höher als vorher, etwas schmaler, Basis wird in den Terrain-Boden versenkt +const VOLCANO_DOME_HEIGHT = 2.1 // 1.6 → 2.1 (+30 %) +const VOLCANO_DOME_RADIUS = 2.2 +const VOLCANO_DOME_SINK = 0.45 // wie tief die Basis IM Terrain steckt +// Schnee ist ein Klon der Kuppel, minimal nach außen verschoben, +// weiß eingefärbt, und wird per clippingPlane von unten weggeschnitten. +const SNOW_OFFSET = 0.03 // radial/axial-Offset zur Vulkan-Kuppel + +// === Höhen-Zonen für Vertex-Farben === +// Unter Wasser → Strand → Wiese → Wald → Fels +// Strand deutlich breiter als vorher, weil er DAS didaktische Element ist +const ZONE_BEACH_END = 0.38 // breiter Strand +const ZONE_MEADOW_END = 0.95 // bis hier helle Wiese +const ZONE_FOREST_END = 1.80 // bis hier dunklerer Wald +// darüber: Fels + +// Helpers +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))) + return t * t * (3 - 2 * t) +} +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +export class KlimawaechterRenderer3D { + private scene: THREE.Scene + private camera: THREE.PerspectiveCamera + private renderer: THREE.WebGLRenderer + private game: KlimawaechterGame + private container: HTMLElement + private placed: Placed[] = [] + private knownIds = new Set() + private animId = 0 + private t = 0 + + // Szenen-Elemente + private water!: THREE.Mesh + private sky!: THREE.Mesh + private island!: THREE.Mesh + private volcanoRock!: THREE.Mesh // Berg-Kegel (kahler Fels) + private glacierIce!: THREE.Mesh // Eis-/Schnee-Kappe (Klon der Kuppel) + private glacierSnow!: THREE.Mesh // (alt, ungenutzt) + private glacierBaseScale = 1.0 + /** Clipping-Plane für die Schmelze — wandert mit Temperatur hoch */ + private snowClipPlane!: THREE.Plane + /** Welt-Y der Schnee-Basis, damit wir die Clip-Konstante korrekt setzen */ + private snowBaseWorldY = 0 + private villageGroup!: THREE.Group + private smokeGroup!: THREE.Group + // Pro Haus ein eigener Puff-Satz (kleine Wölkchen mit individuellen Timings) + private smokePuffs: SmokePuff[][] = [] + private houseAnchors: THREE.Vector3[] = [] + private trees: Array<{ + mesh: THREE.Object3D + kind: 'palm' | 'leaf' | 'conifer' + anchorY: number + stage: 'alive' | 'dying' | 'dead' // grün → braun → schwarzer Strunk + }> = [] + /** Schwebende FX-Effekte (Totenköpfe, Boote, ...) */ + private fxEffects: FxEffect[] = [] + private fxGroup!: THREE.Group + /** Statischer Lawinen-Schutzwall am Vulkanfuß (einmalig, wenn vom Bürger-Event gewählt) */ + private mountainShieldGroup: THREE.Group | null = null + /** Steg am vorderen Strand — Bewohner warten dort, Boote legen dort an */ + private pierGroup!: THREE.Group + /** Wartende Bewohner-Sprites am Steg (immer sichtbar) */ + private waitingFigures: THREE.Mesh[] = [] + /** Insel-Leben: Wolken-Sprites am Himmel (werden bei Hitze dunkler) */ + private ambientClouds: THREE.Mesh[] = [] + /** Möwen am Strand — verschwinden ab Temperatur >17°C nacheinander */ + private ambientGulls: THREE.Mesh[] = [] + /** Statisches ankerndes Fischerboot in der Bucht — verschwindet wenn Tourismus aktiv */ + private ambientFishingBoat: THREE.Group | null = null + /** Position der Steg-Spitze (Bootsanlegestelle) — wird in buildPier gesetzt */ + private pierEndPos = new THREE.Vector3(0, 0, 0) + /** Letzter bekannter Bevölkerungs-Wert (für Sprung-Erkennung) */ + private lastPopulation = 10000 + /** Wie viele Bäume bisher wegen Stromausfall gefällt wurden (Renderer-Sicht) */ + private lastChoppedRendered = 0 + + // === Baueditor / Placement-Mode === + /** Aktuell zu platzierende Maßnahme (null = kein Placement-Mode) */ + private placementMeasureId: string | null = null + /** Ghost-Mesh, das mit dem Mauszeiger mitwandert */ + private placementGhost: THREE.Object3D | null = null + /** Materialien des Ghost-Meshes (für Farb-Update grün/rot) */ + private placementGhostMats: THREE.MeshStandardMaterial[] = [] + /** Aktuelle Welt-Position des Ghosts */ + private placementCurrentPos = { x: 0, z: 0 } + /** Ist die aktuelle Position gültig? */ + private placementCurrentValid = false + /** Raycaster für Maus → Welt-Koordinaten */ + private placementRaycaster = new THREE.Raycaster() + private placementMouseNDC = new THREE.Vector2() + /** Callback ans HTML-Script, wenn Placement bestätigt wird */ + private placementOnConfirm: ((id: string, x: number, z: number) => void) | null = null + /** Callback ans HTML-Script, wenn Placement abgebrochen wird */ + private placementOnCancel: (() => void) | null = null + private resizeHandler: () => void + + // Kamera-Steuerung (sphärische Koordinaten um das Inselzentrum) + private camTheta = Math.PI * 0.12 + private camPhi = Math.PI * 0.34 // flacher Blick für den Keil + private camRadius = 30 // passend zur größeren Insel + private droneActive = true + private droneResumeAt = 0 + private isDragging = false + private lastPointerX = 0 + private lastPointerY = 0 + private pointerDownHandler: (e: PointerEvent) => void + private pointerMoveHandler: (e: PointerEvent) => void + private pointerUpHandler: (e: PointerEvent) => void + private wheelHandler: (e: WheelEvent) => void + + constructor(container: HTMLElement, game: KlimawaechterGame) { + this.container = container + this.game = game + + this.scene = new THREE.Scene() + this.scene.background = new THREE.Color('#bcd5e6') + + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + this.renderer.shadowMap.enabled = true + // PCFShadowMap ist günstiger als die Soft-Variante + this.renderer.shadowMap.type = THREE.BasicShadowMap + + const { width, height } = this.measure() + this.renderer.setSize(width, height) + this.renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;' + container.appendChild(this.renderer.domElement) + + this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 600) + this.applyCamera() + + // === Licht === + const ambient = new THREE.AmbientLight(0xfff0d8, 0.7) + this.scene.add(ambient) + const sun = new THREE.DirectionalLight(0xffe8c0, 1.1) + sun.position.set(20, 28, 12) + sun.castShadow = true + // Schatten-Map klein halten — spart deutlich GPU-Last + sun.shadow.mapSize.set(512, 512) + sun.shadow.camera.left = -30 + sun.shadow.camera.right = 30 + sun.shadow.camera.top = 30 + sun.shadow.camera.bottom = -30 + sun.shadow.camera.near = 0.1 + sun.shadow.camera.far = 100 + this.scene.add(sun) + + // === Himmel === + const skyGeom = new THREE.SphereGeometry(300, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2) + const skyMat = new THREE.MeshBasicMaterial({ color: 0xbcd5e6, side: THREE.BackSide }) + this.sky = new THREE.Mesh(skyGeom, skyMat) + this.scene.add(this.sky) + + // Sonne (am Himmel — nur deko) + const sunSphere = new THREE.Mesh( + new THREE.SphereGeometry(2.5, 24, 16), + new THREE.MeshBasicMaterial({ color: 0xffe4a0 }), + ) + sunSphere.position.set(28, 36, -18) + this.scene.add(sunSphere) + + // === Meer === + const waterGeom = new THREE.PlaneGeometry(400, 400, 1, 1) + const waterMat = new THREE.MeshStandardMaterial({ + color: 0x2a78b8, + roughness: 0.55, + metalness: 0.15, + }) + this.water = new THREE.Mesh(waterGeom, waterMat) + this.water.rotation.x = -Math.PI / 2 + this.water.position.y = WATER_Y_BASE + this.water.receiveShadow = true + this.scene.add(this.water) + + // === Insel als großes Terrain mit integriertem Vulkan === + // Statt separater Cylinder + Plane bauen wir EIN Höhenfeld. Der Vulkan + // wächst aus dem Terrain als Gauss-Erhebung. Vertex-Farben markieren + // Strand / Wiese / Wald / Fels-Zonen. + const segX = 90 + const segZ = 120 + const islandGeom = new THREE.PlaneGeometry( + ISLAND_HALF_WIDTH * 2.4, + ISLAND_HALF_LENGTH * 2.4, + segX, + segZ, + ) + islandGeom.rotateX(-Math.PI / 2) + const pos = islandGeom.attributes.position as THREE.BufferAttribute + const colorArr = new Float32Array(pos.count * 3) + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i) + const z = pos.getZ(i) + const y = this.terrainHeight(x, z) + pos.setY(i, y) + // Vertex-Farbe nach Höhe bestimmen + const c = this.zoneColor(y) + colorArr[i * 3] = c[0] + colorArr[i * 3 + 1] = c[1] + colorArr[i * 3 + 2] = c[2] + } + islandGeom.setAttribute('color', new THREE.BufferAttribute(colorArr, 3)) + islandGeom.computeVertexNormals() + + const islandMat = new THREE.MeshStandardMaterial({ + vertexColors: true, + roughness: 1, + flatShading: false, + }) + this.island = new THREE.Mesh(islandGeom, islandMat) + this.island.receiveShadow = true + this.island.castShadow = true + this.scene.add(this.island) + // Referenz-Kopie für Farbe-Update (Klimastress färbt später) + ;(this.island as any).baseColors = new Float32Array(colorArr) + + // === Vulkan + Gletscher (am Rand der Insel) === + this.buildVolcano() + + // === Pre-existierendes Dorf === + this.villageGroup = new THREE.Group() + this.scene.add(this.villageGroup) + this.smokeGroup = new THREE.Group() + this.scene.add(this.smokeGroup) + this.fxGroup = new THREE.Group() + this.scene.add(this.fxGroup) + this.buildStartingVillage() + this.buildSmokePlumes() + this.buildPier() + this.buildAmbientLife() + // Initiale Bevölkerung merken + this.lastPopulation = this.game.getResource('population') + + // === Resize === + this.resizeHandler = () => this.onResize() + window.addEventListener('resize', this.resizeHandler) + + // === Maus-Steuerung === + this.pointerDownHandler = (e: PointerEvent) => this.onPointerDown(e) + this.pointerMoveHandler = (e: PointerEvent) => this.onPointerMove(e) + this.pointerUpHandler = () => this.onPointerUp() + this.wheelHandler = (e: WheelEvent) => this.onWheel(e) + const dom = this.renderer.domElement + dom.style.cursor = 'grab' + dom.style.touchAction = 'none' + dom.addEventListener('pointerdown', this.pointerDownHandler) + window.addEventListener('pointermove', this.pointerMoveHandler) + window.addEventListener('pointerup', this.pointerUpHandler) + dom.addEventListener('wheel', this.wheelHandler, { passive: false }) + // Im Placement-Mode: Browser-Kontextmenü auf Rechtsklick unterdrücken + dom.addEventListener('contextmenu', (e) => { + if (this.placementMeasureId) e.preventDefault() + }) + // Tastatur: + // - ESC: Placement-Mode beenden + // - Pfeiltasten links/rechts: Insel manuell drehen + // - Pfeiltasten oben/unten: Kamera anheben/senken + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.placementMeasureId) { + this.cancelPlacement() + return + } + const step = 0.08 + if (e.key === 'ArrowLeft') { this.camTheta -= step; this.pauseDrone() } + if (e.key === 'ArrowRight') { this.camTheta += step; this.pauseDrone() } + if (e.key === 'ArrowUp') { this.camPhi = Math.max(0.10, this.camPhi - step * 0.6); this.pauseDrone() } + if (e.key === 'ArrowDown') { this.camPhi = Math.min(Math.PI * 0.48, this.camPhi + step * 0.6); this.pauseDrone() } + }) + } + + // ============================================================ + // Public API für UI-Buttons + // ============================================================ + + zoomIn(): void { + this.camRadius = Math.max(14, this.camRadius - 4) + this.pauseDrone() + } + + zoomOut(): void { + this.camRadius = Math.min(70, this.camRadius + 4) + this.pauseDrone() + } + + recenter(): void { + this.camTheta = Math.PI * 0.12 + this.camPhi = Math.PI * 0.34 + this.camRadius = 30 + this.droneActive = true + } + + // ============================================================ + // Baueditor — Public API + // ============================================================ + + /** + * Startet den Placement-Mode für eine Maßnahme. Erzeugt ein Ghost-Mesh, + * das der Maus folgt. Klick → onConfirm(id, x, z). ESC oder rechter + * Mausklick → onCancel(). + */ + startPlacement( + measureId: string, + onConfirm: (id: string, x: number, z: number) => void, + onCancel: () => void, + ): void { + if (this.placementMeasureId) this.cancelPlacement() + this.placementMeasureId = measureId + this.placementOnConfirm = onConfirm + this.placementOnCancel = onCancel + // Während des Baumodus: Drohnen-Drehung anhalten, damit der Spieler + // ruhig zielen kann. Mit Pfeiltasten kann manuell gedreht werden. + this.droneActive = false + this.droneResumeAt = Number.POSITIVE_INFINITY + + // Ghost-Mesh erzeugen — wir nutzen die existierenden Make-Helper, + // klonen das Mesh und tauschen alle Materialien gegen halbtransparente. + const ghost = this.makeMeasureMeshFor(measureId, 0) + if (!ghost) { + this.placementMeasureId = null + return + } + this.placementGhostMats = [] + ghost.traverse((obj) => { + const mesh = obj as THREE.Mesh + if (mesh.isMesh && mesh.material) { + // Original-Material klonen, transparent machen + const orig = mesh.material as THREE.MeshStandardMaterial + const clone = orig.clone() + clone.transparent = true + clone.opacity = 0.55 + clone.depthWrite = false + mesh.material = clone + this.placementGhostMats.push(clone) + } + }) + this.placementGhost = ghost + this.scene.add(ghost) + // Position initial außerhalb der Sicht + ghost.position.set(0, -100, 0) + this.renderer.domElement.style.cursor = 'crosshair' + } + + cancelPlacement(): void { + if (!this.placementMeasureId) return + const cb = this.placementOnCancel + this.placementMeasureId = null + if (this.placementGhost) { + this.scene.remove(this.placementGhost) + this.placementGhost.traverse((obj) => { + const m = (obj as THREE.Mesh) + if (m.geometry) m.geometry.dispose?.() + }) + this.placementGhostMats.forEach(m => m.dispose()) + this.placementGhostMats = [] + this.placementGhost = null + } + this.placementOnConfirm = null + this.placementOnCancel = null + this.renderer.domElement.style.cursor = 'grab' + // Drohne wieder aktivieren (mit kurzer Pause, damit der Spieler die + // letzte Stellung erstmal sehen kann) + this.droneResumeAt = this.t + 4 + if (cb) cb() + } + + /** Wird intern aufgerufen, wenn eine Maus-Bewegung im Placement-Mode passiert. */ + private updatePlacementGhost(clientX: number, clientY: number): void { + if (!this.placementMeasureId || !this.placementGhost) return + const id = this.placementMeasureId + const isShoreObject = id === 'dike' || id === 'sea-wall' || id === 'mangrove' + const isSand = id === 'sand-fill' + + // Maus → NDC + const rect = this.renderer.domElement.getBoundingClientRect() + this.placementMouseNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1 + this.placementMouseNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1 + this.placementRaycaster.setFromCamera(this.placementMouseNDC, this.camera) + + // Raycast: für Sand-Aufschüttung gegen INSEL UND WASSER, sodass auch + // Klicks aufs offene Meer als Position verwendet werden können. + // Für alle anderen Maßnahmen reicht die Insel. + const targets: THREE.Object3D[] = isSand + ? [this.island, this.water] + : [this.island] + const hits = this.placementRaycaster.intersectObjects(targets, false) + if (hits.length === 0) { + this.placementGhost.position.y = -100 + this.placementCurrentValid = false + return + } + // Nimm den nächsten Treffer (Wasser oder Insel — das, was näher zur Kamera ist) + const hit = hits[0] + const x = hit.point.x + const z = hit.point.z + const y = this.terrainHeight(x, z) + this.placementCurrentPos.x = x + this.placementCurrentPos.z = z + + // Validität prüfen + let valid = true + let reason = '' + if (this.isInVolcanoArea(x, z, 0.5)) { + valid = false + reason = 'Vulkan-Bereich' + } else if (isSand) { + // Sand: KOMPLETT frei. Egal wo der Spieler klickt — solange es nicht + // mitten im Vulkan ist, wird ein Hügel platziert. Auch keine Höhen- + // beschränkung mehr — der Spieler darf sogar auf der Wiese aufschütten. + } else if (isShoreObject) { + if (y < 0.05 || y > 0.45) { valid = false; reason = 'nicht am Strand' } + } else { + if (y < 0.40) { valid = false; reason = 'zu nah am Wasser' } + if (y > 1.7) { valid = false; reason = 'auf dem Felsen' } + } + // Mindestabstand zu existierenden Maßnahmen — bei Sand komplett ignoriert, + // damit Hügel direkt nebeneinander aufgeschüttet werden können. + if (valid && !isSand) { + const minDist2 = 1.0 + for (const p of this.placed) { + const dx = p.mesh.position.x - x + const dz = p.mesh.position.z - z + if (dx * dx + dz * dz < minDist2) { valid = false; reason = 'zu nah an einem Bauwerk'; break } + } + } + this.placementCurrentValid = valid + + // Ghost positionieren — bei Sand auf den Wasserspiegel/Boden (je nachdem + // was höher ist), damit der Hügel auch sichtbar aus dem Wasser ragt + if (isSand) { + const ghostY = Math.max(this.water.position.y - 0.05, y) + this.placementGhost.position.set(x, ghostY, z) + } else { + this.placementGhost.position.set(x, Math.max(0, y) + 0.02, z) + } + + // Farbe des Ghosts grün/rot tönen + const tintR = valid ? 0.55 : 1.0 + const tintG = valid ? 1.0 : 0.45 + const tintB = valid ? 0.55 : 0.45 + for (const mat of this.placementGhostMats) { + // Wir multiplizieren das Material color mit dem Tint, indem wir + // emissive setzen. Einfacher: color setzen und merken. + mat.color.setRGB(tintR, tintG, tintB) + } + } + + /** + * Wird beim Klick im Placement-Mode aufgerufen. + * + * MEHRFACH-BAU: Nach dem Bestätigen bleibt der Placement-Mode aktiv — + * der Ghost folgt weiter dem Mauszeiger, und der nächste Klick baut + * eine weitere Instanz. Beendet wird der Mode nur durch: + * - ESC-Taste + * - Rechtsklick + * - externes cancelPlacement() (z. B. wenn das Budget nicht mehr reicht) + * + * Der HTML-Callback (onConfirm) prüft nach jedem Bau, ob noch genug + * Budget für eine weitere Einheit da ist, und ruft sonst cancelPlacement. + */ + private confirmPlacement(): void { + if (!this.placementMeasureId) return + + // Ungueltige Platzierung → Blubb-Effekt im Wasser zeigen + if (!this.placementCurrentValid) { + const { x, z } = this.placementCurrentPos + const y = this.terrainHeight(x, z) + if (y < 0.15) { + // Im Wasser: Blubb-Blasen-Effekt + this.spawnWaterBubble(x, z) + } + return + } + + const id = this.placementMeasureId + const { x, z } = this.placementCurrentPos + const cb = this.placementOnConfirm + // Wichtig: Aktuelle Position als ungültig markieren, damit ein direkter + // zweiter Klick auf dieselbe Stelle nicht doppelt baut. Erst nach + // einer Mausbewegung (updatePlacementGhost) wird wieder validiert. + this.placementCurrentValid = false + if (cb) cb(id, x, z) + // KEIN Cleanup — Placement-Mode bleibt aktiv für Mehrfach-Bau. + } + + /** Blubb-Blasen im Wasser — visuelles Feedback wenn man ins Wasser klickt */ + private spawnWaterBubble(x: number, z: number): void { + const waterY = this.water.position.y + for (let i = 0; i < 5; i++) { + const geom = new THREE.SphereGeometry(0.04 + Math.random() * 0.06, 8, 6) + const mat = new THREE.MeshStandardMaterial({ + color: 0xaaddff, + transparent: true, + opacity: 0.6, + roughness: 0.1, + metalness: 0.3, + }) + const bubble = new THREE.Mesh(geom, mat) + const bx = x + (Math.random() - 0.5) * 0.3 + const bz = z + (Math.random() - 0.5) * 0.3 + bubble.position.set(bx, waterY, bz) + this.scene.add(bubble) + + // Animation: Blasen steigen auf und verschwinden + const startTime = performance.now() + const speed = 0.3 + Math.random() * 0.4 + const lifetime = 800 + Math.random() * 600 + const animate = () => { + const elapsed = performance.now() - startTime + if (elapsed > lifetime) { + this.scene.remove(bubble) + geom.dispose() + mat.dispose() + return + } + const t = elapsed / lifetime + bubble.position.y = waterY + t * speed + mat.opacity = 0.6 * (1 - t) + bubble.scale.setScalar(1 + t * 0.5) + requestAnimationFrame(animate) + } + // Leicht versetzt starten + setTimeout(animate, i * 100) + } + } + + /** + * Hilfs-Routine: erzeugt ein Mesh für die gegebene Maßnahme OHNE es in + * die Szene einzufügen. Nutzt dieselben Make-Helper wie placeMeasure. + */ + private makeMeasureMeshFor(typeId: string, index: number): THREE.Object3D | null { + const angle = 0 + let obj: THREE.Object3D | null = null + switch (typeId) { + case 'forest': obj = this.makeTree(); break + case 'solar': obj = this.makeSolarPanel(); break + case 'wind': obj = this.makeWindTurbine(); break + case 'green-roof': obj = this.makeGreenRoof(); break + case 'dike': obj = this.makeDike(angle); break + case 'sea-wall': obj = this.makeSeaWall(angle); break + case 'coal': obj = this.makeCoalPlant(); break + case 'airport': obj = this.makeAirport(); break + case 'cloud-seed': obj = this.makeCloudSeeder(); break + case 'bikes': obj = this.makeBikePath(); break + case 'mangrove': obj = this.makeMangrove(angle); break + case 'sand-fill': obj = this.makeSandFill(angle); break + } + return obj + } + + // ============================================================ + // Maus-Steuerung + // ============================================================ + + private onPointerDown(e: PointerEvent): void { + // Im Placement-Mode: Linksklick = bestätigen, Rechtsklick = abbrechen + if (this.placementMeasureId) { + if (e.button === 2) { + this.cancelPlacement() + return + } + if (e.button === 0) { + this.confirmPlacement() + return + } + return + } + if (e.button !== 0) return + this.isDragging = true + this.lastPointerX = e.clientX + this.lastPointerY = e.clientY + this.renderer.domElement.style.cursor = 'grabbing' + this.pauseDrone() + } + + private onPointerMove(e: PointerEvent): void { + // Im Placement-Mode: Ghost-Mesh nachführen (egal ob Drag oder nicht) + if (this.placementMeasureId) { + this.updatePlacementGhost(e.clientX, e.clientY) + return + } + if (!this.isDragging) return + const dx = e.clientX - this.lastPointerX + const dy = e.clientY - this.lastPointerY + this.lastPointerX = e.clientX + this.lastPointerY = e.clientY + this.camTheta -= dx * 0.005 + this.camPhi = Math.max(0.10, Math.min(Math.PI * 0.48, this.camPhi - dy * 0.005)) + } + + private onPointerUp(): void { + if (this.placementMeasureId) return // Click wird in PointerDown behandelt + if (!this.isDragging) return + this.isDragging = false + this.renderer.domElement.style.cursor = 'grab' + this.pauseDrone() + } + + private onWheel(e: WheelEvent): void { + e.preventDefault() + this.camRadius = Math.max(14, Math.min(70, this.camRadius + e.deltaY * 0.02)) + this.pauseDrone() + } + + private pauseDrone(): void { + this.droneActive = false + this.droneResumeAt = this.t + 4 // nach 4 s ohne Aktivität wieder Drone-Mode + } + + // ============================================================ + // Vorgefertigtes Dorf + // ============================================================ + + /** + * Baut den Vulkan-Gipfel als Kuppel. Sitzt FEST im Terrain-Peak + * (Basis ist um VOLCANO_DOME_SINK ins Terrain versenkt, damit kein Schwebe-Effekt). + * + * Der Schnee ist ein KLON der Kuppelgeometrie, leicht aufgeblasen und weiß. + * Die Schmelze wird über eine Clipping-Plane realisiert, die mit steigender + * Temperatur von unten nach oben durch den Schnee wandert — alles unterhalb + * der Ebene wird abgeschnitten, Rest bleibt opak und korrekt beschattet. + */ + private buildVolcano(): void { + // Terrain-Höhe direkt am Vulkanzentrum + const terrainPeakY = this.terrainHeight(VOLCANO_X, VOLCANO_Z) + // Basis der Kuppel sitzt VOLCANO_DOME_SINK UNTER dem Terrain-Peak. + // Dadurch wirkt die Kuppel gewachsen, nicht aufgesetzt. + const domeBaseY = terrainPeakY - VOLCANO_DOME_SINK + + // === Kuppel als Lathe-Geometry === + const domePoints: THREE.Vector2[] = [] + const segments = 14 + for (let i = 0; i <= segments; i++) { + const t = i / segments // 0..1 von Basis zur Spitze + // Glockenform durch cos-Profil (außen bei t=0, auf null bei t=1) + const r = VOLCANO_DOME_RADIUS * Math.cos(t * Math.PI / 2) + const y = t * VOLCANO_DOME_HEIGHT + domePoints.push(new THREE.Vector2(r, y)) + } + const domeGeom = new THREE.LatheGeometry(domePoints, 28) + // Leichte Verformung für Felsstruktur + const domePos = domeGeom.attributes.position as THREE.BufferAttribute + for (let i = 0; i < domePos.count; i++) { + const x = domePos.getX(i), y = domePos.getY(i), z = domePos.getZ(i) + const r = Math.sqrt(x * x + z * z) + if (r > 0.01) { + const noise = Math.sin(x * 1.4 + y * 0.7) * 0.07 + Math.cos(z * 1.3) * 0.05 + domePos.setX(i, x + (x / r) * noise) + domePos.setZ(i, z + (z / r) * noise) + } + } + domeGeom.computeVertexNormals() + const rockMat = new THREE.MeshStandardMaterial({ + color: 0x6a5848, + roughness: 1, + flatShading: true, + }) + this.volcanoRock = new THREE.Mesh(domeGeom, rockMat) + this.volcanoRock.position.set(VOLCANO_X, domeBaseY, VOLCANO_Z) + this.volcanoRock.castShadow = true + this.volcanoRock.receiveShadow = true + this.scene.add(this.volcanoRock) + + // === Schnee-Kappe = Klon der Kuppel, leicht aufgeblasen, weiß === + // Wir klonen die Geometrie und verschieben jeden Vertex 0.03 Units entlang + // der Normalen nach außen — so liegt der Schnee knapp oberhalb der Fels-Oberfläche. + const snowGeom = domeGeom.clone() + const snowPos = snowGeom.attributes.position as THREE.BufferAttribute + const snowNormals = snowGeom.attributes.normal as THREE.BufferAttribute + for (let i = 0; i < snowPos.count; i++) { + const nx = snowNormals.getX(i) + const ny = snowNormals.getY(i) + const nz = snowNormals.getZ(i) + snowPos.setX(i, snowPos.getX(i) + nx * SNOW_OFFSET) + snowPos.setY(i, snowPos.getY(i) + ny * SNOW_OFFSET) + snowPos.setZ(i, snowPos.getZ(i) + nz * SNOW_OFFSET) + } + snowPos.needsUpdate = true + snowGeom.computeVertexNormals() + + // === Clipping-Plane: schneidet den Schnee VON UNTEN ab === + // Normal zeigt nach OBEN (+Y), Konstante wandert mit der Schmelze hoch. + // Alles unterhalb der Ebene wird weggeclipped. + // Die Konstante ist negativ: mehr negativ = Ebene weiter unten → mehr Schnee sichtbar. + // Wir starten bei "alles sichtbar" → Konstante = -(-∞), wir nehmen sehr großen Wert. + this.snowClipPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 1000) + // Renderer muss lokales Clipping aktivieren + this.renderer.localClippingEnabled = true + + const snowMat = new THREE.MeshStandardMaterial({ + color: 0xfafcfd, + roughness: 0.6, + flatShading: true, + clippingPlanes: [this.snowClipPlane], + clipShadows: true, + }) + this.glacierIce = new THREE.Mesh(snowGeom, snowMat) + this.glacierIce.position.set(VOLCANO_X, domeBaseY, VOLCANO_Z) + this.glacierIce.castShadow = true + this.glacierIce.receiveShadow = true + this.scene.add(this.glacierIce) + + // Unused fallback (alter Code referenziert glacierSnow noch irgendwo) + this.glacierSnow = new THREE.Mesh( + new THREE.BufferGeometry(), + new THREE.MeshBasicMaterial({ visible: false }), + ) + this.scene.add(this.glacierSnow) + // Merke die absolute Y-Basis des Schnee-Meshes (für die Clip-Plane Konstante) + this.snowBaseWorldY = domeBaseY + } + + // ============================================================ + // FX-System: schwebende Effekte (Totenköpfe, Flucht-Boote, ...) + // ============================================================ + + /** + * Erzeugt ein Plane-Sprite mit einem Emoji als Textur. + * Das Sprite schaut immer zur Kamera (wird im Update neu ausgerichtet). + */ + private makeEmojiSprite(emoji: string, size = 0.6): THREE.Mesh { + const canvas = document.createElement('canvas') + canvas.width = 128 + canvas.height = 128 + const ctx = canvas.getContext('2d')! + ctx.font = '96px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(emoji, 64, 72) + const tex = new THREE.CanvasTexture(canvas) + tex.minFilter = THREE.LinearFilter + tex.magFilter = THREE.LinearFilter + const mat = new THREE.MeshBasicMaterial({ + map: tex, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }) + const geom = new THREE.PlaneGeometry(size, size) + const mesh = new THREE.Mesh(geom, mat) + mesh.userData.isBillboard = true + return mesh + } + + /** Spawnt einen Totenkopf der über einer Position nach oben schwebt und verblasst. */ + private spawnSkull(x: number, y: number, z: number): void { + const sprite = this.makeEmojiSprite('💀', 0.55) + sprite.position.set(x, y + 0.4, z) + this.fxGroup.add(sprite) + const startY = sprite.position.y + this.fxEffects.push({ + root: sprite, + lifetime: 3.2, + age: 0, + update: (dt, ef) => { + ef.age += dt + const t = ef.age / ef.lifetime + sprite.position.y = startY + t * 1.2 + // leichtes Wackeln + sprite.position.x = x + Math.sin(ef.age * 4) * 0.05 + const opacity = Math.max(0, 1 - t) + ;(sprite.material as THREE.MeshBasicMaterial).opacity = opacity + if (t >= 1) { + this.fxGroup.remove(sprite) + ;(sprite.material as THREE.MeshBasicMaterial).dispose() + sprite.geometry.dispose() + return false + } + return true + }, + }) + } + + /** + * Spawnt ein Flucht-Boot: legt direkt am Steg an, eine wartende Figur + * "schiebt sich" auf das Boot (kurze Boarding-Animation, ~2 s), dann + * fährt das Boot in halbem Tempo eine Spirale um die Insel und verschwindet + * über den Horizont. + * + * Visualisierung: + * - Bootsrumpf groß, Mast + Segel + * - 3 Personen an Bord als Paper-Mario-Sprites (Emoji-Billboards) + * - Eine zusätzliche Boarding-Figur, die vom Steg-Ende auf das Bootsdeck + * interpoliert (lineare Position-Lerp, KEINE Geh-Animation) + */ + private spawnRefugeeBoat(): void { + const group = new THREE.Group() + // Rumpf + const hull = new THREE.Mesh( + new THREE.BoxGeometry(1.8, 0.40, 0.85), + new THREE.MeshStandardMaterial({ color: 0x9a6a3a, roughness: 1 }), + ) + hull.position.y = 0.20 + group.add(hull) + // Bugspitze (kleines Trapez vorne) + const bow = new THREE.Mesh( + new THREE.BoxGeometry(0.45, 0.32, 0.6), + new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 }), + ) + bow.position.set(0.95, 0.22, 0) + group.add(bow) + // Mast + const mast = new THREE.Mesh( + new THREE.CylinderGeometry(0.045, 0.045, 1.15, 6), + new THREE.MeshStandardMaterial({ color: 0x6a4a2a, roughness: 1 }), + ) + mast.position.set(0, 0.85, 0) + group.add(mast) + // Großes Segel + const sail = new THREE.Mesh( + new THREE.BoxGeometry(0.85, 0.95, 0.012), + new THREE.MeshStandardMaterial({ color: 0xf0e8d4, roughness: 1, side: THREE.DoubleSide }), + ) + sail.position.set(0.18, 0.85, 0) + sail.rotation.y = Math.PI / 2 // Segel quer zum Boot + group.add(sail) + + // 3 Passagiere als ganze stehende Menschen (Single-Codepoint-Emojis) + // Sie bekommen userData.isBillboard, damit der Update-Loop sie zur Kamera dreht. + const passengerEmojis = ['🧍', '🚶', '🧍'] + const passengers: THREE.Mesh[] = [] + for (let i = 0; i < 3; i++) { + const sprite = this.makeEmojiSprite(passengerEmojis[i], 0.48) + // Höher gesetzt, damit Füße auf dem Bootsdeck stehen statt im Rumpf + sprite.position.set(-0.45 + i * 0.42, 0.65, 0.0) + group.add(sprite) + passengers.push(sprite) + } + // Boarding-Figur — startet auf dem Steg-Plankenniveau (lokal) + const boarder = this.makeEmojiSprite('🧍', 0.48) + group.add(boarder) + // Boot-relative Zielposition (auf dem Bootsdeck) + const boarderTargetX = 0.55 + const boarderTargetY = 0.65 + const boarderTargetZ = 0.0 + + // === Bewegungs-Choreografie: Boarding → Spirale → Escape === + // Phase 0 (BOARDING): 0..2 s — Boot liegt am Steg, Boarding-Figur gleitet + // Phase 1 (SPIRAL): 2..18 s — eine volle, langsame Umrundung + // Phase 2 (ESCAPE): 18..28 s — tangentiale Flucht über den Horizont + const BOARDING_DUR = 2.0 + const SPIRAL_DUR = 16.0 + const ESCAPE_DUR = 10.0 + const TOTAL_LIFETIME = BOARDING_DUR + SPIRAL_DUR + ESCAPE_DUR + + // Steg-Position als Startpunkt + const startX = this.pierEndPos.x + const startZ = this.pierEndPos.z + const baseY = WATER_Y_BASE + 0.12 + group.position.set(startX, baseY, startZ) + // Boot zeigt initial nach vorne (+Z, Richtung offenes Meer) + group.rotation.y = -Math.PI / 2 + + // Boarding-Figur startet AUSSERHALB des Boots, in lokalen Koordinaten + // links vom Bootsdeck — auf dem Steg-Plankenniveau. + // Plankenoberkante = WORLD_Y 0.85, Bootsorigin = baseY (~0.12), + // also lokal ≈ 0.85 - 0.12 + halbe Sprite-Höhe ≈ 0.97. Wir nehmen 1.0. + // (Lokale Koords, weil Boarder Kind von group ist; group steht aber noch still.) + const boarderStartX = -1.10 + const boarderStartY = 1.00 + const boarderStartZ = 0.0 + boarder.position.set(boarderStartX, boarderStartY, boarderStartZ) + + // Spirale: Insel-Mitte (0,-2), Radius wächst, Uhrzeigersinn + const centerX = 0 + const centerZ = -2 + const r0 = 22 + const r1 = 28 + const spiralStartAngle = Math.atan2(startZ - centerZ, startX - centerX) + + let escapeStartX = 0 + let escapeStartZ = 0 + let escapeDirX = 0 + let escapeDirZ = 0 + let escapeReady = false + + this.fxGroup.add(group) + this.fxEffects.push({ + root: group, + lifetime: TOTAL_LIFETIME, + age: 0, + update: (dt, ef) => { + ef.age += dt + const age = ef.age + + const prevX = group.position.x + const prevZ = group.position.z + + if (age < BOARDING_DUR) { + // === BOARDING === + // Boot bleibt am Steg, Figur gleitet linear in Bootskoordinaten. + const u = age / BOARDING_DUR + boarder.position.x = boarderStartX + (boarderTargetX - boarderStartX) * u + boarder.position.y = boarderStartY + (boarderTargetY - boarderStartY) * u + boarder.position.z = boarderStartZ + (boarderTargetZ - boarderStartZ) * u + // Sanftes Wiegen am Steg + group.rotation.z = Math.sin(age * 2) * 0.03 + } else if (age < BOARDING_DUR + SPIRAL_DUR) { + // === SPIRALE (langsam, eine volle Umrundung) === + const u = (age - BOARDING_DUR) / SPIRAL_DUR + const angle = spiralStartAngle - u * Math.PI * 2 + const r = r0 + (r1 - r0) * u + group.position.x = centerX + Math.cos(angle) * r + group.position.z = centerZ + Math.sin(angle) * r + // Wenn die Spirale fast zu Ende → Escape-Tangente einfrieren + if (u > 0.985 && !escapeReady) { + escapeStartX = group.position.x + escapeStartZ = group.position.z + const vx = group.position.x - prevX + const vz = group.position.z - prevZ + const vlen = Math.sqrt(vx * vx + vz * vz) || 1 + escapeDirX = vx / vlen + escapeDirZ = vz / vlen + escapeReady = true + } + } else { + // === ESCAPE — über den Horizont === + if (!escapeReady) { + escapeStartX = group.position.x + escapeStartZ = group.position.z + escapeDirX = 1 + escapeDirZ = 1 + escapeReady = true + } + const u = (age - BOARDING_DUR - SPIRAL_DUR) / ESCAPE_DUR + // Beschleunigt — gleiche Distanz wie vorher (~110 Units), + // aber über doppelt so viel Zeit (10 s statt ~5 s) + const dist = Math.pow(u, 1.4) * 110 + group.position.x = escapeStartX + escapeDirX * dist + group.position.z = escapeStartZ + escapeDirZ * dist + } + + // Ausrichtung: Boot zeigt in Bewegungsrichtung (außer beim Boarding) + const dx = group.position.x - prevX + const dz = group.position.z - prevZ + if (age >= BOARDING_DUR && dx * dx + dz * dz > 1e-6) { + group.rotation.y = Math.atan2(-dz, dx) + } + + // Sanftes Wiegen während der Fahrt + if (age >= BOARDING_DUR) { + group.rotation.z = Math.sin(age * 2) * 0.04 + group.rotation.x = Math.sin(age * 1.4) * 0.025 + } + + // Letzte 15 % der Gesamtdauer ausblenden + const t = age / TOTAL_LIFETIME + if (t > 0.85) { + const fade = 1 - (t - 0.85) / 0.15 + group.traverse((obj) => { + const m = (obj as THREE.Mesh).material as THREE.MeshBasicMaterial | THREE.MeshStandardMaterial | undefined + if (m && m.opacity !== undefined) { + m.transparent = true + m.opacity = Math.max(0, fade) + } + }) + } + if (t >= 1) { + this.fxGroup.remove(group) + return false + } + return true + }, + }) + } + + /** Liegt der Punkt im Schatten/Bereich des Vulkans? */ + private isInVolcanoArea(x: number, z: number, padding = 1.5): boolean { + const dx = x - VOLCANO_X + const dz = z - VOLCANO_Z + // Wir nehmen den Kuppel-Radius plus etwas Puffer, + // damit nichts direkt am Vulkan platziert wird + return Math.sqrt(dx * dx + dz * dz) < VOLCANO_DOME_RADIUS + 0.8 + padding + } + + private buildStartingVillage(): void { + // Dorf liegt auf dem mittleren bis hinteren (höheren) Teil der Insel. + // Tiefland vorne (+Z) bleibt leer — wird vom Wasser erobert. + const houseCount = 18 + this.houseAnchors = [] + let placed = 0 + let attempts = 0 + while (placed < houseCount && attempts < 500) { + attempts++ + const rx = this.seededRand(attempts * 13 + 7) * 2 - 1 + const rz = this.seededRand(attempts * 17 + 3) * 2 - 1 + // Dorf-Bereich: mittelbreit (± 7), zentral bis Mitte-hinten (-6..+2 in Z) + const x = rx * 7 + const z = -2 + rz * 6 + if (this.isInVolcanoArea(x, z, 1.0)) continue + const y = this.terrainHeight(x, z) + // Nur auf mittlerer Höhe (Wiese), nicht am Vulkanhang und nicht am Strand + if (y < 0.35 || y > 1.3) continue + const house = this.makeStarterHouse(this.seededRand(placed * 23)) + house.position.set(x, y, z) + house.rotation.y = this.seededRand(placed * 31) * Math.PI * 2 + this.villageGroup.add(house) + this.houseAnchors.push(new THREE.Vector3(x, y + 0.85, z)) + placed++ + } + // Bäume breit verteilt. Typ richtet sich nach Höhenzone: + // Strand (0.15..0.45) → Palme (hitze-resistent) + // Wiese (0.45..1.10) → Laubbaum (mittel) + // Wald (1.10..1.80) → Nadelbaum (empfindlich) + // Wir tracken die Bäume für die spätere Klima-Gesundheit. + this.trees = [] + let treesPlaced = 0 + let treeAttempts = 0 + while (treesPlaced < 32 && treeAttempts < 500) { + treeAttempts++ + const rx = this.seededRand(treeAttempts * 41 + 7) * 2 - 1 + const rz = this.seededRand(treeAttempts * 43 + 11) * 2 - 1 + const x = rx * ISLAND_HALF_WIDTH * 0.85 + const z = rz * ISLAND_HALF_LENGTH * 0.90 + if (this.isInVolcanoArea(x, z, -0.2)) continue + const y = this.terrainHeight(x, z) + let kind: 'palm' | 'leaf' | 'conifer' + if (y >= 0.15 && y < 0.45) kind = 'palm' + else if (y >= 0.45 && y < 1.10) kind = 'leaf' + else if (y >= 1.10 && y < 1.80) kind = 'conifer' + else continue + const tree = this.makeBackgroundTree(kind) + tree.position.set(x, y, z) + // kleine Rotation-Varianz + tree.rotation.y = this.seededRand(treeAttempts * 71 + 3) * Math.PI * 2 + this.villageGroup.add(tree) + this.trees.push({ mesh: tree, kind, anchorY: y, stage: 'alive' }) + treesPlaced++ + } + } + + /** + * Pro Haus eine Menge kleiner Rauchwolken die aufsteigen und driften. + * Jede Puff hat eigene Lebensdauer, Aufstiegsgeschwindigkeit und Wind-Drift. + * Sichtbarkeit = wie viele Häuser noch keinen Solar haben. + */ + private buildSmokePlumes(): void { + this.smokePuffs = [] + for (let i = 0; i < this.houseAnchors.length; i++) { + const puffs: SmokePuff[] = [] + // Pro Haus 3 Puffs, zeitlich versetzt + const count = 3 + for (let j = 0; j < count; j++) { + const baseRadius = 0.08 + Math.random() * 0.06 + const geom = new THREE.SphereGeometry(baseRadius, 10, 8) + const mat = new THREE.MeshStandardMaterial({ + color: 0x9a9088, + transparent: true, + opacity: 0.55, + roughness: 1, + metalness: 0, + }) + const mesh = new THREE.Mesh(geom, mat) + this.smokeGroup.add(mesh) + puffs.push({ + mesh, + lifetime: 2.5 + Math.random() * 1.5, + // Zeitlich versetzt starten — nicht alle gleichzeitig + age: (j / count) * (2.5 + Math.random() * 1.5), + speedY: 0.35 + Math.random() * 0.25, + windAmp: 0.2 + Math.random() * 0.25, + windPhase: Math.random() * Math.PI * 2, + baseRadius, + }) + } + this.smokePuffs.push(puffs) + } + } + + private makeStarterHouse(variant: number): THREE.Object3D { + const group = new THREE.Group() + const w = 0.55 + variant * 0.15 + const d = 0.50 + variant * 0.15 + const h = 0.40 + variant * 0.12 + // Wände + const wallMat = new THREE.MeshStandardMaterial({ + color: variant > 0.6 ? 0xe8d8be : 0xd6c8a8, + roughness: 1, + }) + const walls = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wallMat) + walls.position.y = h / 2 + walls.castShadow = true + walls.receiveShadow = true + group.add(walls) + // Spitzdach + const roofMat = new THREE.MeshStandardMaterial({ + color: variant > 0.5 ? 0x9a4a3a : 0x7a3a2a, + roughness: 1, + }) + const roof = new THREE.Mesh( + new THREE.ConeGeometry(Math.max(w, d) * 0.78, 0.32, 4), + roofMat, + ) + roof.rotation.y = Math.PI / 4 + roof.position.y = h + 0.16 + roof.castShadow = true + group.add(roof) + return group + } + + /** + * Erstellt einen Baum nach Typ mit Klima-Resistenz-Marker. + * - 'palm': sehr resistent, hält bis ~17.5°C + * - 'leaf': mittel resistent, hält bis ~16.8°C + * - 'conifer': empfindlich, wird ab ~16.3°C braun + */ + private makeBackgroundTree(kind: 'palm' | 'leaf' | 'conifer' = 'leaf'): THREE.Object3D { + const group = new THREE.Group() + const leafMat = new THREE.MeshStandardMaterial({ + color: kind === 'palm' ? 0x4ba055 : kind === 'conifer' ? 0x3a6a3a : 0x55994a, + roughness: 1, + }) + const trunkMat = new THREE.MeshStandardMaterial({ + color: kind === 'palm' ? 0x8a6a3a : 0x6a4a2a, + roughness: 1, + }) + + if (kind === 'palm') { + // Schlanker, hoher Stamm + 5 Blatt-Wedel + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.08, 0.95, 6), + trunkMat, + ) + trunk.position.y = 0.475 + trunk.castShadow = true + group.add(trunk) + // Blattwedel: flache Boxen, sternförmig nach außen geneigt + for (let i = 0; i < 5; i++) { + const blade = new THREE.Mesh( + new THREE.BoxGeometry(0.55, 0.04, 0.18), + leafMat, + ) + blade.geometry.translate(0.27, 0, 0) // Mittelpunkt am Stamm + const angle = (i / 5) * Math.PI * 2 + blade.rotation.y = angle + blade.rotation.z = -0.35 // leicht hängend + blade.position.y = 0.95 + blade.castShadow = true + group.add(blade) + } + } else if (kind === 'conifer') { + // Schlanker Stamm + spitzer Kegel + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.08, 0.32, 6), + trunkMat, + ) + trunk.position.y = 0.16 + trunk.castShadow = true + group.add(trunk) + const cone = new THREE.Mesh( + new THREE.ConeGeometry(0.32, 0.95, 8), + leafMat, + ) + cone.position.y = 0.78 + cone.castShadow = true + group.add(cone) + } else { + // Laubbaum: kurzer Stamm + bauschige Krone + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.07, 0.11, 0.42, 6), + trunkMat, + ) + trunk.position.y = 0.21 + trunk.castShadow = true + group.add(trunk) + const crown = new THREE.Mesh( + new THREE.SphereGeometry(0.42, 10, 8), + leafMat, + ) + crown.position.y = 0.72 + crown.castShadow = true + crown.scale.set(1, 0.85, 1) + group.add(crown) + } + + // Marker für das Update-System + group.userData.treeKind = kind + group.userData.leafMat = leafMat + group.userData.trunkMat = trunkMat + return group + } + + // ============================================================ + // Resize + // ============================================================ + + private measure(): { width: number; height: number } { + const rect = this.container.getBoundingClientRect() + return { + width: rect.width || window.innerWidth, + height: rect.height || window.innerHeight, + } + } + + private onResize(): void { + const { width, height } = this.measure() + this.renderer.setSize(width, height) + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + } + + // ============================================================ + // Loop + // ============================================================ + + start(): void { + let lastFrame = performance.now() + const loop = (now: number) => { + const dt = (now - lastFrame) / 1000 + lastFrame = now + this.t += dt + this.updateScene(dt) + this.draw() + this.animId = requestAnimationFrame(loop) + } + this.animId = requestAnimationFrame(loop) + } + + stop(): void { + cancelAnimationFrame(this.animId) + window.removeEventListener('resize', this.resizeHandler) + const dom = this.renderer.domElement + dom.removeEventListener('pointerdown', this.pointerDownHandler) + window.removeEventListener('pointermove', this.pointerMoveHandler) + window.removeEventListener('pointerup', this.pointerUpHandler) + dom.removeEventListener('wheel', this.wheelHandler) + } + + private seededRand(seed: number): number { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453 + return x - Math.floor(x) + } + + /** + * Glatte Höhenfunktion des Insel-Terrains. Besteht aus drei Komponenten: + * 1) Ellipsen-Falloff (Insel taucht sanft ins Meer) + * 2) Keil-Kippung (hinten hoch, vorne flach) + * 3) Gauss-Erhebung für den Vulkan + * Plus leichtes Noise für natürliche Hügel. + */ + private terrainHeight(x: number, z: number): number { + // Ellipsen-Distanz 0 = Mitte, 1 = Rand + const ex = x / ISLAND_HALF_WIDTH + const ez = z / ISLAND_HALF_LENGTH + const edge = Math.sqrt(ex * ex + ez * ez) + // Smoothstep-Falloff: bei 0..0.75 voll, bei 0.75..1.08 glatt runter auf 0 + const falloff = 1 - smoothstep(0.78, 1.08, edge) + + // Keil: hinten (−Z) hoch, vorne (+Z) niedrig + const wedgeT = (z + ISLAND_HALF_LENGTH) / (ISLAND_HALF_LENGTH * 2) + const wedge = ISLAND_HIGH_EDGE_Y * (1 - wedgeT) + ISLAND_LOW_EDGE_Y * wedgeT + + // Vulkan als Gauss-Erhebung + const dvx = x - VOLCANO_X + const dvz = z - VOLCANO_Z + const vDist2 = dvx * dvx + dvz * dvz + const volcanoBump = VOLCANO_BUMP_HEIGHT * + Math.exp(-vDist2 / (VOLCANO_BASE_RADIUS * VOLCANO_BASE_RADIUS)) + + // Sanfte Hügel + const noise = + Math.sin(x * 0.35) * 0.14 + + Math.cos(z * 0.28) * 0.11 + + Math.sin(x * 0.22 + z * 0.18) * 0.09 + + // Kombinieren: alles × Falloff, außerhalb deutlich unter Wasser + const land = (wedge + volcanoBump + noise) * falloff + return land - (1 - falloff) * 2.5 + } + + /** Höhe der Insel an (x,z) — Alias für API-Kompatibilität */ + private islandHeightAt(x: number, z: number): number { + return this.terrainHeight(x, z) + } + + /** Ist der Punkt auf der (trockenen) Insel? */ + private isOnIsland(x: number, z: number, minHeight = 0.1): boolean { + return this.terrainHeight(x, z) >= minHeight + } + + /** + * Farbe eines Vertex basierend auf seiner Höhe. + * Strand → Wiese → Wald → Fels (plus Unterwasser-Dunkel). + */ + private zoneColor(y: number): [number, number, number] { + if (y < 0) { + // unter Wasser: dunkelblau-grau + return [0.25, 0.35, 0.38] + } + if (y < ZONE_BEACH_END) { + // Strand: heller Sand + return [0.94, 0.87, 0.67] + } + if (y < ZONE_MEADOW_END) { + // Helle Wiese + const t = (y - ZONE_BEACH_END) / (ZONE_MEADOW_END - ZONE_BEACH_END) + // Sand-zu-Gras-Gradient für weichen Übergang + const r = lerp(0.82, 0.52, t) + const g = lerp(0.82, 0.72, t) + const b = lerp(0.55, 0.38, t) + return [r, g, b] + } + if (y < ZONE_FOREST_END) { + // Dunkler Wald + const t = (y - ZONE_MEADOW_END) / (ZONE_FOREST_END - ZONE_MEADOW_END) + const r = lerp(0.52, 0.38, t) + const g = lerp(0.72, 0.56, t) + const b = lerp(0.38, 0.30, t) + return [r, g, b] + } + // Fels (Vulkan-Hang) + const t = Math.min(1, (y - ZONE_FOREST_END) / 1.5) + const r = lerp(0.42, 0.48, t) + const g = lerp(0.38, 0.35, t) + const b = lerp(0.32, 0.30, t) + return [r, g, b] + } + + // ============================================================ + // Maßnahmen platzieren + // ============================================================ + + /** + * Erzeugt ein Mesh für die gegebene Maßnahme. Wenn `position` übergeben wird + * (Baueditor-Modus), wird das Mesh dort platziert. Sonst wird die alte + * Auto-Platzierung verwendet (zufällig + heuristisch). + */ + private placeMeasure(typeId: string, index: number, position?: { x: number; z: number }): THREE.Object3D | null { + // Deiche, Mauern, Mangroven, Sand: an die Küstenlinie. Rest aufs Land. + const isShoreObject = typeId === 'dike' || typeId === 'sea-wall' + || typeId === 'mangrove' || typeId === 'sand-fill' + const baseSeed = typeId.charCodeAt(0) * 97 + (typeId.charCodeAt(1) || 0) * 13 + index * 41 + + let x = 0, z = 0, angle = 0 + + if (position) { + // === Baueditor-Modus: feste Position vom Spieler === + x = position.x + z = position.z + // Winkel: bei Ufer-Objekten zur Küstennormalen, sonst Atan2 + angle = Math.atan2(z, x) + } else { + // === Auto-Platzierung (Legacy / Saves vor Baueditor) === + let found = false + if (isShoreObject) { + for (let attempt = 0; attempt < 60; attempt++) { + angle = this.seededRand(baseSeed + attempt * 7) * Math.PI * 2 + if (Math.sin(angle) < -0.3) continue + const rFactor = 0.82 + this.seededRand(baseSeed + attempt * 11) * 0.10 + x = Math.cos(angle) * ISLAND_HALF_WIDTH * rFactor + z = Math.sin(angle) * ISLAND_HALF_LENGTH * rFactor + const h = this.terrainHeight(x, z) + if (h > 0.05 && h < 0.40 && !this.isInVolcanoArea(x, z, -0.3)) { + found = true + break + } + } + if (!found) { + angle = Math.PI / 2 + x = 0 + z = ISLAND_HALF_LENGTH * 0.78 + } + } else { + for (let attempt = 0; attempt < 60; attempt++) { + const rx = this.seededRand(baseSeed + attempt * 7) * 2 - 1 + const rz = this.seededRand(baseSeed + attempt * 11) * 2 - 1 + x = rx * ISLAND_HALF_WIDTH * 0.7 + z = rz * ISLAND_HALF_LENGTH * 0.65 + if (this.isInVolcanoArea(x, z, 0.8)) continue + const h = this.terrainHeight(x, z) + if (h < 0.45 || h > 1.7) continue + found = true + angle = Math.atan2(z, x) + break + } + if (!found) return null + } + } + + let obj: THREE.Object3D | null = null + switch (typeId) { + case 'forest': obj = this.makeTree(); break + case 'solar': obj = this.makeSolarPanel(); break + case 'wind': obj = this.makeWindTurbine(); break + case 'green-roof': obj = this.makeGreenRoof(); break + case 'dike': obj = this.makeDike(angle); break + case 'sea-wall': obj = this.makeSeaWall(angle); break + case 'coal': obj = this.makeCoalPlant(); break + case 'airport': obj = this.makeAirport(); break + case 'cloud-seed': obj = this.makeCloudSeeder(); break + case 'bikes': obj = this.makeBikePath(); break + case 'mangrove': obj = this.makeMangrove(angle); break + case 'sand-fill': obj = this.makeSandFill(angle); break + } + if (!obj) return null + + const y = this.islandHeightAt(x, z) + obj.position.set(x, Math.max(0, y), z) + this.scene.add(obj) + return obj + } + + private makeTree(): THREE.Object3D { + const group = new THREE.Group() + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.08, 0.12, 0.5, 6), + new THREE.MeshStandardMaterial({ color: 0x5a3a22, roughness: 1 }), + ) + trunk.position.y = 0.25 + trunk.castShadow = true + group.add(trunk) + const leaves = new THREE.Mesh( + new THREE.ConeGeometry(0.35, 0.85, 8), + new THREE.MeshStandardMaterial({ color: 0x3a7a4a, roughness: 1 }), + ) + leaves.position.y = 0.9 + leaves.castShadow = true + group.add(leaves) + const leaves2 = new THREE.Mesh( + new THREE.ConeGeometry(0.28, 0.6, 8), + new THREE.MeshStandardMaterial({ color: 0x4a8a5a, roughness: 1 }), + ) + leaves2.position.y = 1.2 + leaves2.castShadow = true + group.add(leaves2) + return group + } + + private makeSolarPanel(): THREE.Object3D { + const group = new THREE.Group() + const stand = new THREE.Mesh( + new THREE.BoxGeometry(0.04, 0.35, 0.04), + new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 1 }), + ) + stand.position.y = 0.18 + group.add(stand) + const panel = new THREE.Mesh( + new THREE.BoxGeometry(0.8, 0.04, 0.5), + new THREE.MeshStandardMaterial({ color: 0x1e3a5a, roughness: 0.4, metalness: 0.3 }), + ) + panel.position.y = 0.38 + panel.rotation.x = -Math.PI / 8 + panel.castShadow = true + group.add(panel) + return group + } + + private makeWindTurbine(): THREE.Object3D { + const group = new THREE.Group() + const mast = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.08, 1.8, 8), + new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 0.6 }), + ) + mast.position.y = 0.9 + mast.castShadow = true + group.add(mast) + const hub = new THREE.Mesh( + new THREE.SphereGeometry(0.08, 12, 8), + new THREE.MeshStandardMaterial({ color: 0xdddddd }), + ) + hub.position.y = 1.8 + group.add(hub) + const rotor = new THREE.Group() + rotor.position.y = 1.8 + for (let i = 0; i < 3; i++) { + const blade = new THREE.Mesh( + new THREE.BoxGeometry(0.05, 0.7, 0.03), + new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.8 }), + ) + blade.geometry.translate(0, 0.35, 0) + blade.rotation.z = (i * Math.PI * 2) / 3 + rotor.add(blade) + } + group.add(rotor) + group.userData.rotorRef = rotor + return group + } + + private makeGreenRoof(): THREE.Object3D { + const group = new THREE.Group() + const house = new THREE.Mesh( + new THREE.BoxGeometry(0.7, 0.5, 0.7), + new THREE.MeshStandardMaterial({ color: 0xd8cfbe, roughness: 1 }), + ) + house.position.y = 0.25 + house.castShadow = true + house.receiveShadow = true + group.add(house) + const roof = new THREE.Mesh( + new THREE.BoxGeometry(0.8, 0.08, 0.8), + new THREE.MeshStandardMaterial({ color: 0x5a8a5a, roughness: 1 }), + ) + roof.position.y = 0.54 + roof.castShadow = true + group.add(roof) + return group + } + + private makeDike(angle: number): THREE.Object3D { + const group = new THREE.Group() + // Flacher Erddeich — deutlich kleiner (ca. 1/4 der alten Hoehe) + const dikeBody = new THREE.Mesh( + new THREE.BoxGeometry(2.0, 0.22, 0.6), + new THREE.MeshStandardMaterial({ color: 0x8a6f4a, roughness: 1 }), + ) + dikeBody.position.y = 0.11 + dikeBody.castShadow = true + dikeBody.receiveShadow = true + group.add(dikeBody) + // Grasige Deichkrone + const crown = new THREE.Mesh( + new THREE.BoxGeometry(2.0, 0.04, 0.35), + new THREE.MeshStandardMaterial({ color: 0x5a8a4a, roughness: 1 }), + ) + crown.position.y = 0.24 + crown.castShadow = true + group.add(crown) + // Damit die Längsseite parallel zur Küste verläuft + group.rotation.y = -angle + Math.PI / 2 + return group + } + + private makeSeaWall(angle: number): THREE.Object3D { + const group = new THREE.Group() + // Hohe Betonmauer + const wall = new THREE.Mesh( + new THREE.BoxGeometry(2.6, 1.25, 0.30), + new THREE.MeshStandardMaterial({ color: 0xb0b3b5, roughness: 0.95 }), + ) + wall.position.y = 0.62 + wall.castShadow = true + wall.receiveShadow = true + group.add(wall) + // Krone (dunklerer Streifen oben) + const cap = new THREE.Mesh( + new THREE.BoxGeometry(2.62, 0.08, 0.34), + new THREE.MeshStandardMaterial({ color: 0x70757a, roughness: 1 }), + ) + cap.position.y = 1.28 + group.add(cap) + group.rotation.y = -angle + Math.PI / 2 + return group + } + + /** Kohlekraftwerk: dunkler Block mit zwei Schornsteinen, die qualmen. + * Die Schornsteine bekommen eine eigene Smoke-Marker, damit das System + * später noch dichten Rauch ausstoßen kann. */ + private makeCoalPlant(): THREE.Object3D { + const group = new THREE.Group() + const body = new THREE.Mesh( + new THREE.BoxGeometry(0.95, 0.55, 0.85), + new THREE.MeshStandardMaterial({ color: 0x4a4a52, roughness: 1 }), + ) + body.position.y = 0.28 + body.castShadow = true + body.receiveShadow = true + group.add(body) + // Zwei Schornsteine + for (const sx of [-0.22, 0.22]) { + const stack = new THREE.Mesh( + new THREE.CylinderGeometry(0.09, 0.10, 0.95, 8), + new THREE.MeshStandardMaterial({ color: 0x5a5660, roughness: 1 }), + ) + stack.position.set(sx, 0.78, -0.18) + stack.castShadow = true + group.add(stack) + // Roter Streifen oben + const ring = new THREE.Mesh( + new THREE.CylinderGeometry(0.105, 0.105, 0.10, 8), + new THREE.MeshStandardMaterial({ color: 0xa04a3a, roughness: 1 }), + ) + ring.position.set(sx, 1.20, -0.18) + group.add(ring) + } + return group + } + + /** Tourismus-Flughafen: flaches Terminal mit Tower und einer kleinen Landebahn. */ + private makeAirport(): THREE.Object3D { + const group = new THREE.Group() + // Landebahn + const runway = new THREE.Mesh( + new THREE.BoxGeometry(2.4, 0.04, 0.45), + new THREE.MeshStandardMaterial({ color: 0x404048, roughness: 1 }), + ) + runway.position.y = 0.02 + runway.receiveShadow = true + group.add(runway) + // Markierungsstreifen (heller) + for (let i = -2; i <= 2; i++) { + const stripe = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.002, 0.05), + new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 1 }), + ) + stripe.position.set(i * 0.4, 0.045, 0) + group.add(stripe) + } + // Terminal + const terminal = new THREE.Mesh( + new THREE.BoxGeometry(0.8, 0.32, 0.45), + new THREE.MeshStandardMaterial({ color: 0xe4e0d4, roughness: 1 }), + ) + terminal.position.set(-0.6, 0.18, 0.55) + terminal.castShadow = true + group.add(terminal) + // Tower + const tower = new THREE.Mesh( + new THREE.CylinderGeometry(0.07, 0.08, 0.55, 8), + new THREE.MeshStandardMaterial({ color: 0xd0c8b4, roughness: 1 }), + ) + tower.position.set(-0.95, 0.43, 0.55) + tower.castShadow = true + group.add(tower) + const towerTop = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.10, 0.18), + new THREE.MeshStandardMaterial({ color: 0x2a4a6a, roughness: 0.6 }), + ) + towerTop.position.set(-0.95, 0.78, 0.55) + group.add(towerTop) + return group + } + + /** Wolken-Impfung: Antennen-Mast mit Schüssel — sieht nach Hightech aus. */ + private makeCloudSeeder(): THREE.Object3D { + const group = new THREE.Group() + const base = new THREE.Mesh( + new THREE.BoxGeometry(0.4, 0.18, 0.4), + new THREE.MeshStandardMaterial({ color: 0x9098a0, roughness: 1 }), + ) + base.position.y = 0.09 + group.add(base) + const mast = new THREE.Mesh( + new THREE.CylinderGeometry(0.04, 0.05, 0.95, 8), + new THREE.MeshStandardMaterial({ color: 0xb0b8c0, roughness: 0.6 }), + ) + mast.position.y = 0.65 + mast.castShadow = true + group.add(mast) + // Parabolschüssel oben (halbe Sphäre) + const dish = new THREE.Mesh( + new THREE.SphereGeometry(0.28, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2), + new THREE.MeshStandardMaterial({ + color: 0xe8eef2, + roughness: 0.4, + metalness: 0.5, + side: THREE.DoubleSide, + }), + ) + dish.position.y = 1.10 + dish.rotation.x = -Math.PI / 4 + group.add(dish) + return group + } + + /** Radwege-Netz: kleines flaches Asphalt-Stück mit weißen Streifen + Fahrrad-Symbol-Streifen. */ + private makeBikePath(): THREE.Object3D { + const group = new THREE.Group() + // Asphaltband + const asphalt = new THREE.Mesh( + new THREE.BoxGeometry(1.4, 0.04, 0.45), + new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 1 }), + ) + asphalt.position.y = 0.02 + asphalt.receiveShadow = true + group.add(asphalt) + // Mittelstreifen (weiße Striche) + for (let i = -2; i <= 2; i++) { + const stripe = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.005, 0.04), + new THREE.MeshStandardMaterial({ color: 0xf2f2f2, roughness: 1 }), + ) + stripe.position.set(i * 0.28, 0.045, 0) + group.add(stripe) + } + // Zwei kleine Rad-Stelen am Rand (deko) + for (const sx of [-0.55, 0.55]) { + const post = new THREE.Mesh( + new THREE.CylinderGeometry(0.02, 0.025, 0.22, 5), + new THREE.MeshStandardMaterial({ color: 0x6a8a4a, roughness: 1 }), + ) + post.position.set(sx, 0.13, 0.30) + group.add(post) + const sign = new THREE.Mesh( + new THREE.BoxGeometry(0.13, 0.13, 0.02), + new THREE.MeshStandardMaterial({ color: 0x4a8a6a, roughness: 1 }), + ) + sign.position.set(sx, 0.30, 0.30) + group.add(sign) + } + return group + } + + /** Mangrovenwald: dichter Cluster aus 3 kleinen, gedrungenen Bäumchen + * auf hohen Wurzelstelzen — am Ufer (kann ins Wasser ragen). */ + private makeMangrove(_angle: number): THREE.Object3D { + const group = new THREE.Group() + const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3018, roughness: 1 }) + const leafMat = new THREE.MeshStandardMaterial({ color: 0x2a6a3a, roughness: 1 }) + // 3 Mini-Mangroven + const positions: Array<[number, number]> = [[-0.25, -0.18], [0.22, 0.0], [-0.05, 0.25]] + for (const [px, pz] of positions) { + // Wurzelstelzen — 3 dünne schräge Cylinder + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2 + const root = new THREE.Mesh( + new THREE.CylinderGeometry(0.018, 0.025, 0.32, 5), + trunkMat, + ) + root.position.set(px + Math.cos(a) * 0.06, 0.16, pz + Math.sin(a) * 0.06) + root.rotation.z = Math.cos(a) * 0.4 + root.rotation.x = Math.sin(a) * 0.4 + group.add(root) + } + // Stamm + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.05, 0.06, 0.30, 6), + trunkMat, + ) + trunk.position.set(px, 0.46, pz) + trunk.castShadow = true + group.add(trunk) + // Krone — gedrungen, breit + const leaves = new THREE.Mesh( + new THREE.SphereGeometry(0.25, 10, 8), + leafMat, + ) + leaves.position.set(px, 0.68, pz) + leaves.scale.set(1, 0.7, 1) + leaves.castShadow = true + group.add(leaves) + } + return group + } + + /** + * Sand-Aufschüttung: glockenförmiger Sand-Hügel (LatheGeometry) — wie eine + * Mini-Variante der Vulkan-Kuppel. Wenn die Maßnahme erodiert, lässt der + * Renderer den Hügel in updateScene() langsam einsinken (siehe `userData.maxY`). + * Die Geometrie selbst hat einen Anchor unten, sodass `position.y` direkt + * die Höhe der Spitze über dem Boden bestimmt. + */ + private makeSandFill(_angle: number): THREE.Object3D { + const group = new THREE.Group() + const points: THREE.Vector2[] = [] + const segments = 12 + const radius = 0.85 + const height = 0.42 + for (let i = 0; i <= segments; i++) { + const t = i / segments + // Glockenprofil: r = R * cos(t * π/2) + const r = radius * Math.cos(t * Math.PI / 2) + const y = t * height + points.push(new THREE.Vector2(r, y)) + } + const geom = new THREE.LatheGeometry(points, 18) + // Leichte Welligkeit für Naturlook + const pos = geom.attributes.position as THREE.BufferAttribute + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i) + const noise = Math.sin(x * 5) * 0.015 + Math.cos(z * 4) * 0.012 + pos.setY(i, y + noise) + } + geom.computeVertexNormals() + const sand = new THREE.Mesh( + geom, + new THREE.MeshStandardMaterial({ color: 0xe6cf8a, roughness: 1 }), + ) + sand.castShadow = true + sand.receiveShadow = true + group.add(sand) + // Merken: Maximalhöhe für die Erosions-Animation (siehe updateScene) + group.userData.sandMaxHeight = height + return group + } + + /** + * Holzsteg am vorderen Strand. Statisches Mesh — wird einmal gebaut. + * Drei Holzpfähle aus dem Wasser, ein Plankenweg darüber, am Ende + * stehen 3 wartende Bewohner-Sprites (Paper-Mario-Stil, immer sichtbar). + */ + private buildPier(): void { + const group = new THREE.Group() + const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 }) + const woodMatDark = new THREE.MeshStandardMaterial({ color: 0x6a4520, roughness: 1 }) + + // Steg-Position: vorderes Drittel am Strand, leicht zur Seite + const pierStartZ = ISLAND_HALF_LENGTH * 0.78 // am Ufer + const pierEndZ = ISLAND_HALF_LENGTH * 0.78 + 3.6 // ragt 3.6 Units ins Wasser + const pierX = 1.2 + + // Plankenweg — DEUTLICH HÖHER, damit der Steg nicht früh versinkt. + // Wasser steigt bis ~ +1.6 Units (200 cm Sealevel) → Planken bei 0.85 sind + // bis ca. 100 cm Anstieg sicher. + const plankY = 0.85 + const plankCount = 7 + for (let i = 0; i < plankCount; i++) { + const t = i / (plankCount - 1) + const z = pierStartZ + t * (pierEndZ - pierStartZ) + const plank = new THREE.Mesh( + new THREE.BoxGeometry(0.85, 0.07, 0.45), + i % 2 === 0 ? woodMat : woodMatDark, + ) + plank.position.set(pierX, plankY, z) + plank.castShadow = true + plank.receiveShadow = true + group.add(plank) + } + + // === RAMPE: schiefe Holz-Ebene vom Land aufs Steg-Niveau === + // Geht vom hohen Inland-Punkt (deutlich über Wasser) hoch zur Plankenkante. + // Damit ist der Steg auch bei starkem Meeresspiegelanstieg noch trocken + // erreichbar — die Rampe selbst wird vom Wasser nicht überspült, weil sie + // ja auf dem Inselhang sitzt. + // Wir nehmen einen sicheren Inland-Punkt etwa 3 Units weiter im Z drin + // und holen die echte Terrainhöhe dort ab. + const rampInlandZ = pierStartZ - 3.0 // tiefer in der Insel + const rampSeaZ = pierStartZ - 0.05 // direkt am Plankenanfang + const rampInlandY = this.terrainHeight(pierX, rampInlandZ) + 0.05 + const rampSeaY = plankY // schließt bündig an die Planken + const rampLengthZ = rampSeaZ - rampInlandZ + const rampLengthY = rampSeaY - rampInlandY + const rampLen = Math.sqrt(rampLengthZ * rampLengthZ + rampLengthY * rampLengthY) + const rampAngle = Math.atan2(rampLengthY, rampLengthZ) + + // Hauptbalken (eine schräge Box) + const ramp = new THREE.Mesh( + new THREE.BoxGeometry(0.85, 0.08, rampLen), + woodMat, + ) + // Mittelpunkt der Rampe + ramp.position.set( + pierX, + (rampInlandY + rampSeaY) / 2, + (rampInlandZ + rampSeaZ) / 2, + ) + // Negative Rotation um X-Achse → Z-Achse kippt nach oben in Richtung Steg + ramp.rotation.x = -rampAngle + ramp.castShadow = true + ramp.receiveShadow = true + group.add(ramp) + + // Querlatten auf der Rampe (Anti-Rutsch-Optik), gleichmäßig verteilt + const rungs = 5 + for (let i = 1; i < rungs; i++) { + const t = i / rungs + const rz = rampInlandZ + t * rampLengthZ + const ry = rampInlandY + t * rampLengthY + const rung = new THREE.Mesh( + new THREE.BoxGeometry(0.88, 0.025, 0.10), + woodMatDark, + ) + rung.position.set(pierX, ry + 0.045, rz) + rung.rotation.x = -rampAngle + group.add(rung) + } + + // 4 Holzpfähle — länger, damit sie unter dem höheren Plankenweg den + // Boden im Wasser erreichen + const postH = 1.5 + const postCenterY = plankY - postH / 2 - 0.05 + for (const z of [pierStartZ + 0.4, pierStartZ + 1.6, pierStartZ + 2.7, pierEndZ - 0.1]) { + for (const dx of [-0.32, 0.32]) { + const post = new THREE.Mesh( + new THREE.CylinderGeometry(0.07, 0.085, postH, 6), + woodMatDark, + ) + post.position.set(pierX + dx, postCenterY, z) + post.castShadow = true + group.add(post) + } + } + + // Geländer (etwas höher und länger) + for (const z of [pierStartZ + 0.8, pierStartZ + 2.2]) { + for (const dx of [-0.36, 0.36]) { + const rail = new THREE.Mesh( + new THREE.CylinderGeometry(0.028, 0.028, 0.42, 5), + woodMatDark, + ) + rail.position.set(pierX + dx, plankY + 0.24, z) + group.add(rail) + } + } + + this.scene.add(group) + this.pierGroup = group + // Bootsanlegestelle: vor dem Steg-Ende, knapp über Wasserlinie + this.pierEndPos.set(pierX, WATER_Y_BASE + 0.22, pierEndZ - 0.1) + + // === Wartende Bewohner-Sprites === + // Ganze stehende Menschen (nicht nur Köpfe!) als Emoji-Billboards. + // 🧍 (person standing) ist ein Single-Codepoint-Emoji ohne ZWJ → rendert + // verlässlich auf der Canvas, im Gegensatz zu 🧍‍♂️ / 🧍‍♀️. + const waiters = ['🧍', '🚶', '🧍'] + const figureSize = 0.55 + // Sprite-Mitte sitzt halbe Höhe über den Planken, sodass die Füße + // optisch auf dem Steg stehen. + const figureY = plankY + figureSize / 2 + 0.02 + for (let i = 0; i < 3; i++) { + const sprite = this.makeEmojiSprite(waiters[i], figureSize) + const rowZ = pierStartZ + 1.0 + i * 0.55 + sprite.position.set(pierX + (i % 2 === 0 ? -0.12 : 0.12), figureY, rowZ) + this.scene.add(sprite) + this.waitingFigures.push(sprite) + } + } + + /** + * Insel-Leben: kleine statische Sprites + Modelle, die das Bild beleben. + * + * - ☁️ 4 Wolken am Himmel — werden bei Hitze dunkler / mehr Sturm + * - 🐦 6 Möwen am Strand — verschwinden nacheinander ab Temp >17°C + * - ⛵ 1 ankerndes Fischerboot in der Bucht — verschwindet bei Tourismus + * + * Alles statisch (keine pro-Frame-Update-Logik außer Sichtbarkeit). + */ + private buildAmbientLife(): void { + // === Wolken auf Vulkan-Höhe === + // Die Wolken hängen an der Spitze des Vulkans und daneben — wirkt natürlich + // (Bergwolken um Gipfel) und macht den Vulkan zum Himmels-Anker. + const volcanoPeakY = this.terrainHeight(VOLCANO_X, VOLCANO_Z) - VOLCANO_DOME_SINK + VOLCANO_DOME_HEIGHT + const cloudY = volcanoPeakY + 1.2 // knapp über der Spitze + const cloudPositions: Array<[number, number, number]> = [ + [VOLCANO_X - 1.5, cloudY + 0.4, VOLCANO_Z + 0.5], + [VOLCANO_X + 2.0, cloudY, VOLCANO_Z - 1.0], + [VOLCANO_X + 0.3, cloudY + 1.1, VOLCANO_Z - 2.5], + [VOLCANO_X - 4.0, cloudY - 0.3, VOLCANO_Z + 2.0], + [VOLCANO_X + 4.5, cloudY + 0.6, VOLCANO_Z + 1.5], + ] + for (const [cx, cy, cz] of cloudPositions) { + const cloud = this.makeEmojiSprite('☁️', 2.4) + cloud.position.set(cx, cy, cz) + this.scene.add(cloud) + this.ambientClouds.push(cloud) + } + + // === Statische Boden-Sprites über der Insel === + // KEINE schwebenden Tiere (Vögel, Schmetterlinge) — die sehen ohne + // Animation komisch aus. Stattdessen NUR Sachen, die natürlich am + // Boden sitzen: Tiere auf der Wiese, Blumen, Felsen, Pilze. + // + // Helper: Y-Position aus dem Terrain auslesen, sodass Sprite auf dem + // Boden steht (Sprite-Mitte = Bodenhöhe + halbe Sprite-Größe). + const placeOnGround = (x: number, z: number, size: number): number => + this.terrainHeight(x, z) + size / 2 + 0.02 + + // Bewusst kleine Auswahl — die 3D-Seite ist schon ausgelastet, + // wir vermeiden zu viele Sprites. Schwerpunkt: ein paar Tiere + // auf der Wiese, ein paar Blumen, ein paar Felsen. + const atmoSprites: Array<{ emoji: string; x: number; z: number; size: number }> = [ + // Blumen + { emoji: '🌸', x: -5, z: -3, size: 0.42 }, + { emoji: '🌻', x: 3, z: -5, size: 0.45 }, + { emoji: '🌷', x: -8, z: 1, size: 0.42 }, + { emoji: '🌼', x: 6, z: 4, size: 0.42 }, + // Tiere (alle auf dem Boden) + { emoji: '🐑', x: -2, z: 4, size: 0.55 }, + { emoji: '🐄', x: -6, z: 3, size: 0.62 }, + { emoji: '🐄', x: 5, z: 6, size: 0.62 }, + { emoji: '🐰', x: 7, z: -2, size: 0.42 }, + // Felsen + { emoji: '🪨', x: -11, z: -3, size: 0.42 }, + { emoji: '🪨', x: 11, z: 4, size: 0.42 }, + ] + for (const s of atmoSprites) { + // Maßnahmen-Bereiche und Vulkan überspringen + if (this.isInVolcanoArea(s.x, s.z, 0.5)) continue + const y = this.terrainHeight(s.x, s.z) + if (y < 0.5) continue // zu tief (Strand/Wasser) + const sprite = this.makeEmojiSprite(s.emoji, s.size) + sprite.position.set(s.x, placeOnGround(s.x, s.z, s.size), s.z) + this.scene.add(sprite) + } + + // Sonne als einziges schwebendes Himmels-Element + const sun = this.makeEmojiSprite('☀️', 3.0) + sun.position.set(22, 24, -8) + this.scene.add(sun) + + // === Möwen am Strand === + // KEINE schwebenden Vögel — Möwen stehen direkt auf dem Sand. + // Sparsam: 4 Möwen reichen, Performance. + const gullSpots: Array<[number, number]> = [ + [-8, ISLAND_HALF_LENGTH * 0.85], + [ 5, ISLAND_HALF_LENGTH * 0.88], + [ 9, ISLAND_HALF_LENGTH * 0.82], + [-11, ISLAND_HALF_LENGTH * 0.75], + ] + const gullSize = 0.32 + for (const [gx, gz] of gullSpots) { + if (Math.abs(gx - 1.2) < 1.5 && gz > ISLAND_HALF_LENGTH * 0.7) continue + const y = this.terrainHeight(gx, gz) + if (y < 0.05) continue // im Wasser + const gull = this.makeEmojiSprite('🐦', gullSize) + // Möwe steht auf dem Sand: Y = Boden + halbe Sprite-Höhe + gull.position.set(gx, y + gullSize / 2 + 0.01, gz) + this.scene.add(gull) + this.ambientGulls.push(gull) + } + + // === Ankerndes Fischerboot in der Bucht === + // Klein, statisch, leicht zur Seite vom Steg, im Wasser. + const boatGroup = new THREE.Group() + const hull = new THREE.Mesh( + new THREE.BoxGeometry(1.4, 0.32, 0.65), + new THREE.MeshStandardMaterial({ color: 0x8a5a2a, roughness: 1 }), + ) + hull.position.y = 0.16 + boatGroup.add(hull) + const bow = new THREE.Mesh( + new THREE.BoxGeometry(0.35, 0.26, 0.5), + new THREE.MeshStandardMaterial({ color: 0x7a4a1a, roughness: 1 }), + ) + bow.position.set(0.78, 0.18, 0) + boatGroup.add(bow) + // Mast + Segel + const fishMast = new THREE.Mesh( + new THREE.CylinderGeometry(0.035, 0.035, 0.95, 6), + new THREE.MeshStandardMaterial({ color: 0x6a4a2a, roughness: 1 }), + ) + fishMast.position.set(-0.1, 0.65, 0) + boatGroup.add(fishMast) + const fishSail = new THREE.Mesh( + new THREE.BoxGeometry(0.65, 0.7, 0.012), + new THREE.MeshStandardMaterial({ color: 0xe2d8b8, roughness: 1, side: THREE.DoubleSide }), + ) + fishSail.position.set(0.05, 0.65, 0) + fishSail.rotation.y = Math.PI / 2 + boatGroup.add(fishSail) + // Fischerei-Sprite-Emoji als Kennzeichen + const fishEmoji = this.makeEmojiSprite('🐟', 0.32) + fishEmoji.position.set(-0.5, 0.42, 0.0) + boatGroup.add(fishEmoji) + // Position im Wasser, links vom Steg + boatGroup.position.set(-3.5, WATER_Y_BASE + 0.14, ISLAND_HALF_LENGTH * 0.78 + 2.2) + boatGroup.rotation.y = -Math.PI / 2 - 0.15 + this.scene.add(boatGroup) + this.ambientFishingBoat = boatGroup + } + + /** + * Lawinen-Schutzwall am Vulkan-Fuß. Wird einmalig gebaut, sobald + * `game.hasMountainShield` true ist. Mehrere Mauern-Segmente, die im + * Bogen unter dem Vulkan-Hang hervorragen. + */ + private buildMountainShield(): void { + const group = new THREE.Group() + const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a8782, roughness: 1 }) + const capMat = new THREE.MeshStandardMaterial({ color: 0x6a6862, roughness: 1 }) + const segments = 5 + const arcRadius = VOLCANO_DOME_RADIUS + 1.8 + // Bogen vor dem Vulkan, der zur "Tal-Seite" (positive Z) zeigt + const baseAngle = Math.PI / 2 + const arcSpread = Math.PI * 0.55 // ~ 100° + for (let i = 0; i < segments; i++) { + const t = (i / (segments - 1)) - 0.5 + const angle = baseAngle + t * arcSpread + const x = VOLCANO_X + Math.cos(angle) * arcRadius + const z = VOLCANO_Z + Math.sin(angle) * arcRadius + const yGround = this.terrainHeight(x, z) + const seg = new THREE.Group() + const wall = new THREE.Mesh( + new THREE.BoxGeometry(0.85, 0.7, 0.22), + wallMat, + ) + wall.position.y = 0.35 + wall.castShadow = true + wall.receiveShadow = true + seg.add(wall) + const cap = new THREE.Mesh( + new THREE.BoxGeometry(0.90, 0.07, 0.27), + capMat, + ) + cap.position.y = 0.74 + seg.add(cap) + // Tangentiale Ausrichtung (parallel zum Bogen) + seg.rotation.y = -angle + seg.position.set(x, Math.max(0.05, yGround), z) + group.add(seg) + } + this.scene.add(group) + this.mountainShieldGroup = group + } + + // ============================================================ + // Update + Draw + // ============================================================ + + private updateScene(dt: number): void { + // Lawinen-Schutzwall am Vulkanfuß: einmalig bauen, sobald gewählt + if (!this.mountainShieldGroup && (this.game as any).hasMountainShield) { + this.buildMountainShield() + } + + // Neue gebaute Maßnahmen einfügen + 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) + // Position aus dem OwnedMeasure lesen — wenn vorhanden, dort platzieren + const pos = m.positions?.[i] + const mesh = this.placeMeasure(m.measureId, i, pos || undefined) + if (mesh) { + this.placed.push({ ownerId: id, mesh, typeId: m.measureId }) + } + } + } + } + // Abgerissene Maßnahmen aus der Szene entfernen. + // Pro typeId: erlaubt sind die ersten N Indizes (N = aktueller count). + // Alles darüber wurde demoliert und muss verschwinden. + const allowedByType = new Map() + for (const m of owned) allowedByType.set(m.measureId, m.count) + for (let i = this.placed.length - 1; i >= 0; i--) { + const p = this.placed[i] + const allowedCount = allowedByType.get(p.typeId) ?? 0 + // ownerId hat Form "typeId-INDEX", wir parsen den Index + const idxStr = p.ownerId.split('-').pop() || '0' + const idx = parseInt(idxStr, 10) || 0 + if (idx >= allowedCount) { + this.scene.remove(p.mesh) + // tiefes Aufräumen (Geometrien/Materialien) + p.mesh.traverse((obj) => { + const mesh = obj as THREE.Mesh + if (mesh.geometry) mesh.geometry.dispose?.() + if (mesh.material) { + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + mats.forEach(mat => mat.dispose?.()) + } + }) + this.knownIds.delete(p.ownerId) + this.placed.splice(i, 1) + } + } + + // Rotoren drehen + for (const p of this.placed) { + if (p.typeId === 'wind') { + const rotor = (p.mesh as any).userData?.rotorRef + if (rotor) rotor.rotation.z = this.t * 1.4 + } + } + + // Drohnenflug fortsetzen, wenn keine User-Aktivität + if (!this.droneActive && this.t >= this.droneResumeAt && !this.isDragging) { + this.droneActive = true + } + if (this.droneActive) { + this.camTheta += dt * 0.06 // sehr langsam + } + + // Meeresspiegel: das WASSER steigt immer, unabhängig von Deichen. + // Deiche schützen einzelne Objekte (Häuser), erhöhen aber nicht den Boden. + const sealevel = this.game.getResource('sealevel') + const waterY = WATER_Y_BASE + (sealevel / 200) * MAX_SEA_RISE_UNITS + this.water.position.y = waterY + // Schutzhöhe der Deiche (nur für Sub-Mergence-Check der Häuser) + const protection = (this.game as any).variables?.protection || 0 + const protectionY = (protection / 200) * MAX_SEA_RISE_UNITS + + // Klimastress färbt Himmel und Insel + const temp = this.game.getResource('temperature') + const co2 = this.game.getResource('co2') + const tempStress = Math.max(0, Math.min(1, (temp - 15) / 4)) + const co2Stress = Math.max(0, Math.min(1, (co2 - 425) / 350)) + + // === Gletscher-Schmelze via Clipping-Plane === + // 15.0°C → 100% Schnee, 18.0°C → 0% (nur Spitze / ganz weg). + const snowFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3)) + // Die Clipping-Ebene wandert von der Schnee-Basis nach oben durch + // die gesamte Kuppelhöhe. Alles UNTER der Ebene wird weggeclippt. + // Bei snowFraction=1 → Ebene unter der Basis (alles sichtbar) + // Bei snowFraction=0 → Ebene über der Spitze (alles weg) + const meltY = this.snowBaseWorldY + (1 - snowFraction) * (VOLCANO_DOME_HEIGHT + 0.3) + // Plane-Formel: n·p + constant = 0 → für n=(0,1,0) gilt: constant = -meltY + // Sichtbar ist alles, wo n·p + constant >= 0 → also y >= meltY + this.snowClipPlane.constant = -meltY + + // Schnee verschwindet ganz wenn Fraction unter 1% + this.glacierIce.visible = snowFraction > 0.01 + // Schnee wird leicht grauer/dreckiger bei Schmelze + const iceMat = this.glacierIce.material as THREE.MeshStandardMaterial + const grime = (1 - snowFraction) * 0.16 + iceMat.color.setRGB(0.98 - grime, 0.99 - grime, 1.00 - grime) + + // Himmelsfarbe + const skyR = 188 + co2Stress * 40 + const skyG = 213 - co2Stress * 30 + const skyB = 230 - co2Stress * 60 + ;(this.sky.material as THREE.MeshBasicMaterial).color.setRGB(skyR / 255, skyG / 255, skyB / 255) + this.scene.background = (this.sky.material as THREE.MeshBasicMaterial).color + + // Insel-Tönung durch Klimastress: Emissive leicht orange bei Hitze + // (Vertex-Farben bleiben erhalten, aber Material strahlt wärmer) + const islMat = this.island.material as THREE.MeshStandardMaterial + islMat.color.setRGB( + 1.0 + tempStress * 0.25, // leicht warmer Tint + 1.0 - tempStress * 0.05, + 1.0 - tempStress * 0.15, + ) + + // Wasserfarbe — bleibt blau, wird nur leicht heller bei Hitze + const watR = 42 + tempStress * 15 + const watG = 120 + tempStress * 10 + const watB = 184 - tempStress * 20 + ;(this.water.material as THREE.MeshStandardMaterial).color.setRGB(watR / 255, watG / 255, watB / 255) + + // === Insel-Leben reagiert auf Klima/State === + // Möwen verschwinden ab Temperatur >17°C nacheinander. + // Bei 17°C → 0% sichtbar, bei 16.5°C → 100%. + const gullsAlive = Math.max(0, Math.min(1, 1 - (temp - 16.5) / 0.7)) + const gullsToShow = Math.round(this.ambientGulls.length * gullsAlive) + for (let i = 0; i < this.ambientGulls.length; i++) { + this.ambientGulls[i].visible = i < gullsToShow + } + // Wolken werden bei Hitze leicht grauer (Sturmstimmung) + for (const cloud of this.ambientClouds) { + const m = cloud.material as THREE.MeshBasicMaterial + m.color.setRGB(1 - tempStress * 0.25, 1 - tempStress * 0.25, 1 - tempStress * 0.18) + } + // Fischerboot verschwindet, sobald Tourismus-Modus aktiv wurde + if (this.ambientFishingBoat) { + const tourism = (this.game as any).tourismMode === true + // Auch wenn das Boot im Wasser steht und der Wasserspiegel steigt: + // mitschwimmen lassen, damit es nicht versinkt. + this.ambientFishingBoat.position.y = waterY + 0.14 + this.ambientFishingBoat.visible = !tourism + } + + // Untergetauchte Objekte ausblenden. + // Häuser sind durch Deiche/Mauern um protectionY angehoben geschützt. + const submergeY = waterY - 0.05 + const houseSubmergeY = submergeY - protectionY + // Sand-Erosion: für jede Sand-Aufschüttung das Alter aus dem game-State + // lesen und den Mesh-Hügel entsprechend einsinken lassen. + const ownedNow = this.game.getOwnedMeasures() + let sandIndex = 0 + for (const p of this.placed) { + // Deiche/Mauern selbst gehen nicht unter (sie sollen ja Schutz bieten) + if (p.typeId === 'dike' || p.typeId === 'sea-wall') { + p.mesh.visible = true + continue + } + if (p.typeId === 'sand-fill') { + // Pro Sand-Mesh den passenden owned-Eintrag finden (jede Instanz hat + // einen eigenen owned-Eintrag, weil buyMeasure für 'sand-fill' immer + // neue Gruppen anlegt). Wir nehmen sie der Reihe nach. + const sandOwneds = ownedNow.filter(o => o.measureId === 'sand-fill') + const owned = sandOwneds[sandIndex] + sandIndex++ + if (owned) { + const ageYears = Math.max(0, this.game.getSnapshot().tick - owned.builtAt) + // 9 Jahre = ganz weg (Game halbiert die Schutz-Wirkung mit 2 cm/Jahr aus 18 cm) + const remaining = Math.max(0.05, 1 - ageYears / 9) + p.mesh.scale.y = remaining + // Sand bleibt sichtbar, schmilzt nur visuell + p.mesh.visible = true + } + continue + } + p.mesh.visible = p.mesh.position.y >= houseSubmergeY + } + // Auch Dorf-Häuser können absaufen — durch Deiche aber später + for (const child of this.villageGroup.children) { + child.visible = child.position.y >= houseSubmergeY + } + + // === Bevölkerungs-Sprung → Flüchtlingsboot === + const currentPop = this.game.getResource('population') + if (currentPop < this.lastPopulation - 60) { + // Ein größerer Sprung nach unten → Boot starten + this.spawnRefugeeBoat() + this.lastPopulation = currentPop + } else if (currentPop > this.lastPopulation) { + this.lastPopulation = currentPop + } + + // === FX-Effekte aktualisieren === + // Billboards (Totenköpfe) zur Kamera ausrichten + for (let i = this.fxEffects.length - 1; i >= 0; i--) { + const ef = this.fxEffects[i] + const alive = ef.update(dt, ef) + if (!alive) { + this.fxEffects.splice(i, 1) + continue + } + // Billboards: Orientierung zur Kamera + ef.root.traverse((obj) => { + if ((obj as any).userData?.isBillboard) { + obj.lookAt(this.camera.position) + } + }) + } + + // === Baum-Gesundheit nach Typ (3 Stadien) === + // alive: grün, voller Baum + // dying: braun, Blätter noch da + // dead: schwarzer Strunk, KEINE Blätter mehr (werden unsichtbar) + const treeHealth = (kind: 'palm' | 'leaf' | 'conifer'): number => { + let tStart: number, tDead: number + if (kind === 'conifer') { tStart = 15.8; tDead = 17.3 } + else if (kind === 'leaf') { tStart = 16.3; tDead = 17.8 } + else { tStart = 17.0; tDead = 18.2 } // palm + if (temp <= tStart) return 1 + if (temp >= tDead) return 0 + return 1 - (temp - tStart) / (tDead - tStart) + } + for (const tree of this.trees) { + const health = treeHealth(tree.kind) + const ud = (tree.mesh as any).userData + const leafMat = ud?.leafMat as THREE.MeshStandardMaterial | undefined + const trunkMat = ud?.trunkMat as THREE.MeshStandardMaterial | undefined + if (!leafMat) continue + + // Stadium bestimmen + let nextStage: 'alive' | 'dying' | 'dead' + if (health > 0.55) nextStage = 'alive' + else if (health > 0.05) nextStage = 'dying' + else nextStage = 'dead' + + // Grundfarbe je Typ + let gR = 0.33, gG = 0.60, gB = 0.29 + if (tree.kind === 'palm') { gR = 0.29; gG = 0.63; gB = 0.33 } + if (tree.kind === 'conifer') { gR = 0.22; gG = 0.42; gB = 0.22 } + const brownR = 0.48, brownG = 0.32, brownB = 0.14 + const blackR = 0.12, blackG = 0.08, blackB = 0.05 + + if (nextStage === 'alive') { + // Farbübergang grün → braun + const t = (health - 0.55) / 0.45 // 0..1 innerhalb alive + leafMat.color.setRGB( + lerp(brownR, gR, t), + lerp(brownG, gG, t), + lerp(brownB, gB, t), + ) + // Blätter + Stamm sichtbar + tree.mesh.children.forEach(c => (c.visible = true)) + } else if (nextStage === 'dying') { + // Voll braun, Blätter hängen noch + leafMat.color.setRGB(brownR, brownG, brownB) + tree.mesh.children.forEach(c => (c.visible = true)) + } else { + // Tot: Baum kippt um und liegt am Boden, sinkt langsam ein. + // Stamm → dunkelbraun/schwarz, Blätter → braun dann unsichtbar + if (trunkMat) { + trunkMat.color.setRGB(blackR, blackG, blackB) + } + + // Umkipp-Animation: rotation.z dreht sich auf ~PI/2 (liegt auf der Seite) + if (!ud.fallStarted) { + ud.fallStarted = true + ud.fallProgress = 0 + ud.fallDir = Math.random() > 0.5 ? 1 : -1 + ud.sinkProgress = 0 + } + // Umfallen (schnell, ~2s bei 60fps Render-Calls) + if (ud.fallProgress < 1) { + ud.fallProgress = Math.min(1, ud.fallProgress + 0.02) + // Ease-out Kurve fuer natuerliches Fallen + const t = 1 - Math.pow(1 - ud.fallProgress, 2) + tree.mesh.rotation.z = t * (Math.PI / 2) * ud.fallDir * 0.85 + } else { + // Liegt am Boden → langsam einsinken + ud.sinkProgress = Math.min(1, (ud.sinkProgress || 0) + 0.001) + tree.mesh.position.y = tree.anchorY - ud.sinkProgress * 0.4 + } + + // Blaetter ausblenden nach dem Fallen + const leafFade = Math.max(0, 1 - (ud.fallProgress || 0) * 1.5) + tree.mesh.children.forEach(c => { + const cm = (c as THREE.Mesh) + if (cm.material === leafMat) { + c.visible = leafFade > 0.05 + leafMat.opacity = leafFade + leafMat.transparent = true + } else { + c.visible = true + } + }) + tree.mesh.scale.setScalar(0.92) + } + + // Übergang von alive/dying → dead: Totenkopf-Effekt einmalig + if (tree.stage !== 'dead' && nextStage === 'dead') { + this.spawnSkull(tree.mesh.position.x, tree.mesh.position.y + 0.5, tree.mesh.position.z) + } + tree.stage = nextStage + } + + // === Bäume verheizen bei Stromausfall === + // Das Game zählt einen kumulativen Counter `treesChoppedForHeat`. + // Sobald er größer ist als das, was der Renderer schon getötet hat, + // wählen wir die nächsten lebendigen Bäume aus und setzen sie hart auf 'dead'. + // Der Stamm bleibt sichtbar (schwarzer Strunk), Blätter verschwinden, + // ein Totenkopf-Sprite signalisiert die Fällung. + const choppedTotal = (this.game as any).treesChoppedForHeat || 0 + if (choppedTotal > this.lastChoppedRendered) { + const need = choppedTotal - this.lastChoppedRendered + let killed = 0 + for (const tree of this.trees) { + if (killed >= need) break + if (tree.stage === 'dead') continue + // Hart als 'dead' markieren — die nächste Frame der oberen Schleife + // zeichnet ihn im finalen Stadium (schwarzer Strunk). + const ud = (tree.mesh as any).userData + const leafMat = ud?.leafMat as THREE.MeshStandardMaterial | undefined + const trunkMat = ud?.trunkMat as THREE.MeshStandardMaterial | undefined + if (leafMat && trunkMat) { + trunkMat.color.setRGB(0.12, 0.08, 0.05) + tree.mesh.children.forEach(c => { + const cm = c as THREE.Mesh + if (cm.material === leafMat) c.visible = false + }) + tree.mesh.scale.setScalar(0.92) + } + tree.stage = 'dead' + this.spawnSkull(tree.mesh.position.x, tree.mesh.position.y + 0.5, tree.mesh.position.z) + killed++ + } + this.lastChoppedRendered = choppedTotal + } + + // === Heizwolken: individuell animierte Puffs pro Haus === + // Jede Solar- oder Gründach-Anlage deckt ein Haus ab. + const solarCount = this.game.getMeasureCount('solar') + const greenRoofCount = this.game.getMeasureCount('green-roof') + const totalCovered = solarCount + greenRoofCount + const housesWithSmoke = Math.max(0, this.houseAnchors.length - totalCovered) + // Globale Windrichtung (langsam wechselnd) + const globalWind = Math.sin(this.t * 0.3) * 0.6 + 0.4 + + for (let i = 0; i < this.smokePuffs.length; i++) { + const anchor = this.houseAnchors[i] + const houseAlive = anchor.y >= submergeY + const shouldSmoke = houseAlive && i < housesWithSmoke + + for (const puff of this.smokePuffs[i]) { + puff.mesh.visible = shouldSmoke + if (!shouldSmoke) continue + // Altern + puff.age += dt + if (puff.age > puff.lifetime) { + // Neue Runde — klein und unten neu starten + puff.age = 0 + puff.speedY = 0.35 + Math.random() * 0.25 + puff.windAmp = 0.2 + Math.random() * 0.25 + puff.windPhase = Math.random() * Math.PI * 2 + } + const t01 = puff.age / puff.lifetime // 0..1 + // Aufstieg + const y = anchor.y + 0.05 + t01 * puff.speedY * puff.lifetime + // Wind-Drift (horizontal) — je älter desto weiter + const drift = Math.sin(puff.windPhase + this.t * 0.8) * puff.windAmp * t01 + const xOff = drift * globalWind + const zOff = drift * 0.5 + // Größe wächst mit Alter, Opacity sinkt am Ende + const growth = 1 + t01 * 1.8 + const opacity = (1 - t01) * 0.65 + puff.mesh.position.set(anchor.x + xOff, y, anchor.z + zOff) + puff.mesh.scale.setScalar(growth) + ;(puff.mesh.material as THREE.MeshStandardMaterial).opacity = opacity + } + } + } + + private applyCamera(): void { + const sinPhi = Math.sin(this.camPhi) + const cosPhi = Math.cos(this.camPhi) + const x = Math.sin(this.camTheta) * sinPhi * this.camRadius + const y = cosPhi * this.camRadius + const z = Math.cos(this.camTheta) * sinPhi * this.camRadius + this.camera.position.set(x, y, z) + this.camera.lookAt(0, 0.5, 0) + } + + private draw(): void { + this.applyCamera() + this.renderer.render(this.scene, this.camera) + } +} diff --git a/App/src/sims/sim-05-treibhaus/game-renderer.ts b/App/src/sims/sim-05-treibhaus/game-renderer.ts new file mode 100644 index 0000000..11c7f3a --- /dev/null +++ b/App/src/sims/sim-05-treibhaus/game-renderer.ts @@ -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() + 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 + } +} diff --git a/App/src/sims/sim-05-treibhaus/game.ts b/App/src/sims/sim-05-treibhaus/game.ts new file mode 100644 index 0000000..5ca869a --- /dev/null +++ b/App/src/sims/sim-05-treibhaus/game.ts @@ -0,0 +1,1091 @@ +/** + * SIM-05: Klimawächter — Interaktive Treibhauseffekt-Simulation + * + * Du bist Klimaminister*in eines Inselstaats. 75 Jahre lang musst du: + * - die Wirtschaft am Laufen halten (Steuern → Budget) + * - CO₂-Emissionen kontrollieren (durch Maßnahmen) + * - die Bevölkerung vor steigendem Meeresspiegel schützen + * + * Fachlich korrekt: echte Klimasensitivität (3°C / Verdoppelung CO₂), + * echte Meeresspiegelanstieg-Werte, echte Maßnahmen-Effekte. + * + * Mechanik: + * - 75 Ticks = 75 Jahre (2025 → 2100) + * - Pro Tick: Budget +/-, CO₂ steigt/fällt, Temp ändert sich, Meer steigt + * - Anwender*in baut Maßnahmen (Solar, Wind, Wald, Deiche, …) + * - Win: bis 2100 überleben mit positivem Budget + Klimazielen + * - Loss: Pleite ODER > 50% der Stadt überflutet + */ + +import { GameEngine, type GameMeta, type TutorialStep, type CitizenEvent } from '@core/game-engine' +import { computeTemperature } from './logic' + +const META: GameMeta = { + id: 'sim-05', + title: 'Klimawächter', + description: '2025–2100 als Klimaminister*in eines Inselstaats. Schaffst du es, deine Stadt zu retten?', + msPerTick: 4000, // 4 Sekunden = 1 Jahr bei Speed 1 → 5 min Gesamtdauer + tickUnit: 'Jahr', + maxTicks: 75, + tutorialSteps: 5, +} + +const START_YEAR = 2025 + +/** + * Drei Schwierigkeitsgrade. Anwender*in wählt einen, Lehrperson kann später + * via URL-Parameter ?level=N erzwingen. + * + * Alle Level haben dieselben Maßnahmen + Mechaniken — sie unterscheiden + * sich nur in: Startgeld, Steuereinnahmen, Schonfristen und der Härte + * der Folgen. + */ +export interface DifficultyConfig { + id: 1 | 2 | 3 + label: string + emoji: string + description: string + startBudget: number + startPopulation: number + /** Mio € Steuern pro 10.000 Einwohner / Jahr */ + incomePer10k: number + /** Anzahl Schonjahre, bevor Stromausfall Folgen hat */ + blackoutGrace: number + /** Bäume pro Stromausfall-Jahr (über Schonfrist hinaus) */ + treesPerBlackout: number + /** ppm CO₂ pro Stromausfall-Jahr (Holzfeuer) */ + co2BlackoutPerYear: number + /** Multiplikator für Klima-Folgekosten (Versalzung etc.) */ + climateDamageMul: number + /** Multiplikator für Bevölkerungs-Verlust durch Klima-Folgen */ + popLossMul: number + /** Welche Maßnahmen sind in diesem Level kaufbar? Leer = alle. */ + availableMeasures?: string[] + /** Welche Citizen-Event-IDs sollen NICHT ausgelöst werden? */ + skipCitizenEvents?: string[] + /** Tick-Verschiebung für Citizen-Events (Level 1 = später, weil weniger Druck) */ + citizenEventTickOffset?: number +} + +export const DIFFICULTY_LEVELS: Record<1 | 2 | 3, DifficultyConfig> = { + 1: { + id: 1, label: 'Lernen', emoji: '🟢', + description: 'Sanfter Einstieg. Viel Geld, milde Folgen, lange Schonzeit. Nur die wichtigsten Maßnahmen.', + startBudget: 600, startPopulation: 6000, incomePer10k: 220, + blackoutGrace: 5, treesPerBlackout: 1, co2BlackoutPerYear: 0.15, + climateDamageMul: 0.35, popLossMul: 0.35, + // Reduziert auf 5 Kern-Maßnahmen — keine Fallen, keine Doppelungen + availableMeasures: ['forest', 'solar', 'wind', 'green-roof', 'dike'], + // Alle Bürger-Events außer der ersten (Lina) ausblenden + skipCitizenEvents: ['citizen-scientist', 'citizen-farmer', 'citizen-tourism', + 'citizen-youth', 'citizen-industry', 'citizen-mountain'], + // Lina kommt 10 Jahre später als sonst (Tick 25 statt 15) + citizenEventTickOffset: 10, + }, + 2: { + id: 2, label: 'Üben', emoji: '🟡', + description: 'Standard. Strategie nötig, du musst nachdenken. Mehr Auswahl, alle Bürger-Events.', + startBudget: 480, startPopulation: 6000, incomePer10k: 155, + blackoutGrace: 0, treesPerBlackout: 1, co2BlackoutPerYear: 0.4, + climateDamageMul: 1.2, popLossMul: 1.1, + // Wolken-Impfung und Flughafen weg — die didaktisch fragwürdigsten Optionen + availableMeasures: ['forest', 'solar', 'wind', 'green-roof', 'dike', + 'sea-wall', 'coal', 'bikes', 'mangrove', 'sand-fill'], + }, + 3: { + id: 3, label: 'Profi', emoji: '🔴', + description: 'Hart. Wenig Spielraum, schnelle Konsequenzen. Alle Maßnahmen — auch die fragwürdigen.', + startBudget: 220, startPopulation: 7000, incomePer10k: 110, + blackoutGrace: 0, treesPerBlackout: 2, co2BlackoutPerYear: 0.7, + climateDamageMul: 2.2, popLossMul: 1.8, + // Alle Maßnahmen verfügbar (kein Filter) + }, +} + +/** Maßnahmen, die die Anwender*in bauen kann */ +export interface Measure { + id: string + name: string + emoji: string + description: string + cost: number // Einmalige Kosten (€) + upkeep: number // Jährliche Kosten (€) + co2Reduction: number // ppm/Jahr Reduktion + protection: number // Cm Schutz vor Meeresspiegelanstieg + powerOutput?: number // MW Strom-Erzeugung + prerequisite?: string // Vorgänger-Maßnahme nötig? +} + +// Sehr feine Granularität: kleine Einheiten, viele kaufbar. +// Wirkung pro investiertem Mio € bleibt konstant — nur die Mindest-Schritte +// werden kleiner, sodass mehr Streuung und Reparatur möglich ist. +// Wald hat bewusst die GERINGSTE Wartung pro CO₂-Reduktion (Naturlösung). +export const MEASURES: Measure[] = [ + { + id: 'forest', + name: 'Wald aufforsten', + emoji: '🌲', + description: 'Bäume binden langfristig CO₂. Sehr günstig im Unterhalt — die Naturlösung.', + cost: 12, upkeep: 0.5, + co2Reduction: 0.04, protection: 0, + }, + { + id: 'solar', + name: 'Solaranlage', + emoji: '☀️', + description: 'Klein und günstig — sofort kaufbar. Wartung ist allerdings mittelhoch.', + cost: 40, upkeep: 2, + co2Reduction: 0.14, protection: 0, + powerOutput: 1, + }, + { + id: 'wind', + name: 'Windpark', + emoji: '🌬️', + description: 'Großes Windrad — hohe Anschaffung, dafür sehr günstig im Betrieb und viel Strom.', + cost: 150, upkeep: 4, + co2Reduction: 0.50, protection: 0, + powerOutput: 3, + }, + { + id: 'green-roof', + name: 'Gründächer', + emoji: '🏡', + description: 'Begrünte Dächer kühlen die Stadt. Kleine Wirkung, dafür ohne jährliche Kosten.', + cost: 30, upkeep: 0, + co2Reduction: 0.04, protection: 0, + }, + { + id: 'bikes', + name: 'Radwege-Netz', + emoji: '🚲', + description: 'Macht die Insel fahrradfreundlich. Günstig, kein Strom nötig — ideal für den Anfang.', + cost: 18, upkeep: 0.5, + co2Reduction: 0.06, protection: 0, + }, + { + id: 'mangrove', + name: 'Mangrovenwald', + emoji: '🪸', + description: 'Mangroven binden CO₂ UND schützen die Küste vor Erosion. Die Naturlösung — beides in einem.', + cost: 35, upkeep: 1, + co2Reduction: 0.06, protection: 6, + }, + { + id: 'sand-fill', + name: 'Sand-Aufschüttung', + emoji: '🏖', + description: 'Künstlicher Strand. Gibt sofort viel Schutz — wird aber jedes Jahr vom Meer wieder weggespült.', + cost: 50, upkeep: 0, + co2Reduction: 0, protection: 18, + }, + { + id: 'dike', + name: 'Deich bauen', + emoji: '🌊', + description: 'Niedrige Hürde — schnell baubar, aber teurer pro cm Schutz.', + cost: 80, upkeep: 3, + co2Reduction: 0, protection: 12, + }, + { + id: 'sea-wall', + name: 'Hochwasserschutz', + emoji: '🛡️', + description: 'Hohe Hürde, aber deutlich effizienter pro Mio €. Sehr starker Schutz.', + cost: 240, upkeep: 6, + co2Reduction: 0, protection: 50, + }, + // === Maßnahmen, die nur SCHEINBAR helfen — didaktische Fallen === + { + id: 'coal', + name: 'Kohlekraftwerk', + emoji: '🏭', + description: 'Liefert viel Strom auf einmal — billig und sofort. Verbrennt aber Kohle und stößt viel CO₂ aus.', + cost: 20, upkeep: 1, + co2Reduction: -0.38, protection: 0, + powerOutput: 4, + }, + { + id: 'airport', + name: 'Tourismus-Flughafen', + emoji: '✈️', + description: 'Bringt Touristen auf die Insel. Mehr Wirtschaft — aber Flugverkehr bedeutet viel CO₂.', + cost: 62, upkeep: 3, + co2Reduction: -0.22, protection: 0, + }, + { + id: 'cloud-seed', + name: 'Wolken-Impfung', + emoji: '🌤️', + description: 'Geo-Engineering: Wolken werden mit Salz besprüht. Klingt nach Wundermittel — wirkt aber nur ein bisschen.', + cost: 125, upkeep: 4.5, + co2Reduction: 0.04, protection: 0, + }, +] + +/** + * Eine vom Spieler gebaute Maßnahme. + * + * Das `positions`-Array enthält die Welt-Koordinaten für jeden gebauten Index + * (in derselben Reihenfolge, wie die Maßnahmen platziert wurden). Ist ein + * Index nicht enthalten, wird die Position vom Renderer zufällig gewählt + * (Auto-Platzierung — Rückwärtskompatibilität für Saves vor dem Baueditor). + */ +interface OwnedMeasure { + measureId: string + builtAt: number + count: number + positions?: Array<{ x: number; z: number }> +} + +export class KlimawaechterGame extends GameEngine { + private measures: OwnedMeasure[] = [] + /** Aktuelle Schwierigkeit — bestimmt Startwerte und Härte der Folgen */ + private diff: DifficultyConfig = DIFFICULTY_LEVELS[2] + + // Echte Klima-Werte + private co2Ppm = 425 // Aktuelles CO₂ (2025) + private baselineTemp = 15 // °C vorindustriell + private currentTemp = 0 + private targetTemp = 0 // Gleichgewichtstemperatur (instantan aus CO₂) + private seaLevelCm = 0 // cm über Startniveau + private committedSeaCm = 0 // committed sea level rise (träge) + private floodedHouses = 0 // % + /** Discount-Faktor für Maßnahmenkosten (1.0 = normal, 0.8 = 20% billiger) */ + researchDiscount = 1.0 + /** + * Tourismus-Modus: Wurde durch Bürger-Event "Fischerei → Tourismus" aktiviert. + * Bringt jährlich Extra-Einnahmen, kostet dafür CO₂ und macht die Insel sehr + * abhängig vom Strand — sobald der Meeresspiegel den Strand frisst, kollabiert er. + */ + tourismMode = false + /** Lawinen-/Murenschutzwall am Bergdorf gebaut (vom Bürger-Event "Anna") */ + hasMountainShield = false + + /** + * Kumulative Anzahl Bäume, die wegen Stromausfall gefällt wurden. + * Der Renderer liest diesen Counter pro Frame und tötet die nötige + * Anzahl Bäume sichtbar (damit sie braun/schwarz werden). + */ + treesChoppedForHeat = 0 + /** Wie viele Jahre in Folge gerade Stromausfall herrscht (für Eskalation) */ + private blackoutStreak = 0 + + // CO₂ kann auch mit massivem Klimaschutz nicht unter ~350 ppm fallen (Senken arbeiten langsam) + private static readonly CO2_FLOOR = 350 + // Untergrenze: vorindustrielles Niveau — kein "Eiszeit-Bug" durch Übermaßnahmen + private static readonly TEMP_FLOOR = 15.0 + + constructor(difficulty: 1 | 2 | 3 = 2) { + super(META) + this.diff = DIFFICULTY_LEVELS[difficulty] + this.setupResources() + this.setupGoals() + this.setupTutorial() + this.setupVariables() + this.computeClimate() + } + + /** Aktuelle Schwierigkeitsstufe (für UI-Anzeige). */ + getDifficulty(): DifficultyConfig { + return this.diff + } + + private setupResources(): void { + this.addResource({ + id: 'budget', + name: 'Budget', + icon: '💰', + initial: this.diff.startBudget, + unit: 'Mio €', + format: (v) => `${Math.round(v)} Mio €`, + }) + this.addResource({ + id: 'population', + name: 'Bevölkerung', + icon: '👥', + initial: this.diff.startPopulation, + unit: 'Menschen', + format: (v) => `${Math.round(v).toLocaleString('de-AT')}`, + }) + // Strom-Bilanz: Wert ist die Kapazität (MW), das Format zeigt + // gleichzeitig Bedarf/Kapazität an. Bedarf wächst mit Bevölkerung + // (1 MW pro 1.000 Einwohner — kindgerechte Faustzahl). + this.addResource({ + id: 'power', + name: 'Strom', + icon: '⚡', + initial: 0, + unit: 'MW', + format: (v) => { + const demand = Math.max(0, Math.round(this.getResource('population') / 1000)) + const cap = Math.round(v) + return `${demand}/${cap} MW` + }, + }) + this.addResource({ + id: 'co2', + name: 'CO₂-Konzentration', + icon: '🌫️', + initial: 425, + unit: 'ppm', + format: (v) => `${v.toFixed(0)} ppm`, + }) + this.addResource({ + id: 'temperature', + name: 'Globale Temperatur', + icon: '🌡️', + initial: 15.5, + unit: '°C', + format: (v) => `${v.toFixed(1)}°C`, + }) + this.addResource({ + id: 'sealevel', + name: 'Meeresspiegel', + icon: '🌊', + initial: 0, + unit: 'cm', + format: (v) => `+${Math.round(v)} cm`, + }) + this.addResource({ + id: 'flooded', + name: 'Überflutete Gebiete', + icon: '🏚️', + initial: 0, + unit: '%', + format: (v) => `${v.toFixed(0)}%`, + }) + } + + private setupGoals(): void { + this.addGoal({ + id: 'survive', + title: 'Bis 2100 überleben', + description: 'Halte deine Stadt 75 Jahre lang am Leben (2025–2100).', + check: (g) => (g as KlimawaechterGame).tick >= 75, + progress: (g) => Math.min(100, ((g as KlimawaechterGame).tick / 75) * 100), + required: true, + }) + this.addGoal({ + id: 'temp', + title: 'Temperatur unter +2°C halten', + description: 'Die globale Temperatur darf nicht über 17°C steigen.', + check: (g) => (g as KlimawaechterGame).currentTemp < 17, + progress: (g) => { + const t = (g as KlimawaechterGame).currentTemp + return Math.max(0, Math.min(100, (1 - (t - 15) / 2) * 100)) + }, + required: true, + }) + this.addGoal({ + id: 'budget', + title: 'Nicht pleite gehen', + description: 'Halte ein positives Budget.', + check: (g) => g.getResource('budget') > 0, + required: true, + }) + this.addGoal({ + id: 'flooding', + title: 'Stadt schützen', + description: 'Maximal 30% der Stadt dürfen überflutet sein.', + check: (g) => (g as KlimawaechterGame).floodedHouses < 30, + progress: (g) => Math.max(0, 100 - (g as KlimawaechterGame).floodedHouses * 3.33), + required: true, + }) + } + + private setupTutorial(): void { + this.setTutorial([ + { + triggerTick: 0, + title: 'Willkommen, Klimaminister*in!', + text: 'Es ist das Jahr 2025. Du übernimmst die Verantwortung für eine kleine Inselstadt mit 10.000 Einwohnern. Bis zum Jahr 2100 liegt ihr Schicksal in deiner Hand.\n\nDie Wissenschaft warnt: Das Klima verändert sich. CO₂ in der Atmosphäre steigt. Wenn du nichts tust, wird die Temperatur steigen, das Eis schmelzen — und der Meeresspiegel deine Stadt überfluten.\n\nAber Schutzmaßnahmen kosten Geld. Und du hast nur 450 Mio € zum Start. Plane sparsam!', + unlocks: ['budget-display', 'population-display'], + }, + { + triggerTick: 0, + title: 'Das Klimasystem', + text: 'Hier links siehst du die wichtigsten Zahlen:\n\n🌫️ CO₂ steigt aktuell mit ca. 2,5 ppm pro Jahr\n🌡️ Die Temperatur reagiert auf CO₂ — pro Verdoppelung etwa +3°C\n🌊 Wärmer = mehr Eis schmilzt = höherer Meeresspiegel\n\n📌 Wichtig: Diese Insel ist ein vereinfachtes Modell der ganzen Welt. Wenn du hier CO₂ reduzierst, repräsentiert das die globale Klimapolitik. Im echten Leben kann eine einzelne Stadt das Weltklima nicht alleine retten — aber für unsere Lern-Simulation zählt: Was du hier tust, wirkt auch auf das ganze Klima.', + unlocks: ['climate-display', 'graphs'], + }, + { + triggerTick: 0, + title: 'Strom für die Insel', + text: 'Deine Insel braucht ⚡ Strom — etwa 1 MW pro 1.000 Einwohner. Oben links siehst du, wie viel gerade gebraucht wird und wie viel deine Kraftwerke liefern (z.B. „6/8 MW").\n\n☀️ Solaranlage = +1 MW (sauber, langsam Wirkung)\n🌬️ Windpark = +3 MW (sauber, teuer)\n🏭 Kohlekraftwerk = +8 MW (sehr viel auf einmal — aber viel CO₂!)\n\nFehlt Strom, wird es kalt: die Leute fällen Bäume und verbrennen das Holz. Das setzt zusätzliches CO₂ frei und tote Bäume binden kein CO₂ mehr.', + unlocks: ['power-display'], + }, + { + triggerTick: 0, + title: 'Maßnahmen kaufen', + text: 'Rechts siehst du Maßnahmen, die du kaufen kannst.\n\n🌲 Wald: günstig, kleine Wirkung\n☀️ Solar: mittlerer Preis, sauberer Strom\n🌬️ Wind: teuer, viel sauberer Strom\n🛡️ Deiche: schützen vor Überflutung\n\nJede Maßnahme hat Anschaffungskosten UND jährliche Wartung. Wenn du dich verbaust, kannst du eine Maßnahme mit dem 🗑-Knopf auch wieder abreißen — du bekommst dann die Hälfte des Geldes zurück.', + unlocks: ['measures'], + }, + { + triggerTick: 0, + title: 'Bereit?', + text: 'Du kannst die Simulation jederzeit pausieren ⏸️ oder schneller laufen lassen ⏩.\n\nJedes Jahr bekommst du Steuereinnahmen (~150 €, abhängig von der Bevölkerungsgröße) — aber du musst die Wartungskosten deiner Maßnahmen davon bezahlen.\n\nWichtig: Das Klima reagiert träge. Eine Maßnahme heute wirkt sich erst nach Jahren voll aus. Und der Meeresspiegel steigt auch noch, wenn das CO₂ längst sinkt.\n\nDeine Aufgabe: bis 2100 überleben. Halte Temperatur unter +2°C, schütze die Stadt, gehe nicht pleite.\n\nViel Erfolg! 🌍', + unlocks: ['controls'], + }, + ]) + } + + private setupVariables(): void { + this.setVariable('co2Reduction', 0) + this.setVariable('protection', 0) + this.setVariable('upkeepTotal', 0) + } + + private computeClimate(): void { + // Gleichgewichtstemperatur (was instantan herauskommt) + this.targetTemp = computeTemperature(this.co2Ppm, 0.3) + + // Aktuelle Temperatur folgt der Zieltemperatur TRÄGE nach + // (Ozeantraegheit: pro Jahr ~8% Anpassung) + if (this.currentTemp === 0) { + this.currentTemp = this.targetTemp + } else { + this.currentTemp += (this.targetTemp - this.currentTemp) * 0.08 + } + // Untergrenze: keine künstliche Abkühlung unter vorindustriell + if (this.currentTemp < KlimawaechterGame.TEMP_FLOOR) { + this.currentTemp = KlimawaechterGame.TEMP_FLOOR + } + + // Meeresspiegel: ~30 cm pro °C über vorindustriell (vereinfacht aus IPCC AR6 SSP3-7.0) + // Sehr träge: einmal angestiegen, sinkt er kaum (committed rise) + const deltaT = Math.max(0, this.currentTemp - 15) + const targetSea = deltaT * 30 + if (targetSea > this.seaLevelCm) { + // Meer steigt sichtbar + this.seaLevelCm += (targetSea - this.seaLevelCm) * 0.08 + } else { + // Aber sinkt nur ganz langsam (Eis-Schmelze nicht reversibel) + this.seaLevelCm += (targetSea - this.seaLevelCm) * 0.005 + } + + // Überflutung: ab 35 cm effektivem Anstieg verlieren wir Häuser + const protectionTotal = this.getVariable('protection') + const effectiveRise = Math.max(0, this.seaLevelCm - protectionTotal) + if (effectiveRise > 35) { + this.floodedHouses = Math.min(100, (effectiveRise - 35) * 0.9) + } else { + this.floodedHouses = 0 + } + + this.setResource('co2', this.co2Ppm) + this.setResource('temperature', this.currentTemp) + this.setResource('sealevel', this.seaLevelCm) + this.setResource('flooded', this.floodedHouses) + } + + /** CO₂-Emissionen pro Jahr (BAU). Steigen leicht bis 2050, dann konstant. */ + private getEmissionsThisYear(): number { + // 2025: 2.5 ppm/Jahr → 2050: 3.2 ppm/Jahr (BAU-Wachstum) → konstant + const rampYears = 25 + const startEm = 2.5 + const peakEm = 3.2 + if (this.tick < rampYears) { + return startEm + (peakEm - startEm) * (this.tick / rampYears) + } + return peakEm + } + + /** + * Anwender*in baut eine Maßnahme. + * + * Wenn `position` übergeben wird (Baueditor-Modus), wird die Maßnahme an + * dieser Stelle eingetragen — der Renderer liest später die `positions` + * der OwnedMeasure aus und platziert dort. Ohne Position fällt der + * Renderer auf seine zufällige Auto-Platzierung zurück. + */ + buyMeasure(measureId: string, position?: { x: number; z: number }): boolean { + const m = MEASURES.find(x => x.id === measureId) + if (!m) return false + const effectiveCost = Math.round(m.cost * this.researchDiscount) + const budget = this.getResource('budget') + if (budget < effectiveCost) { + this.addEvent('error', `Nicht genug Budget für ${m.name}`, 'warning') + return false + } + + this.changeResource('budget', -effectiveCost) + + // Maßnahme registrieren. + // Spezialfall sand-fill: jede Instanz braucht ein eigenes builtAt + // (Erosion läuft je Instanz unterschiedlich schnell ab), deshalb + // immer ein NEUER OwnedMeasure-Eintrag mit count=1. + let owned: OwnedMeasure + if (measureId === 'sand-fill') { + owned = { measureId, builtAt: this.tick, count: 1, positions: [] } + this.measures.push(owned) + } else { + const existing = this.measures.find(x => x.measureId === measureId) + if (existing) { + owned = existing + } else { + owned = { measureId, builtAt: this.tick, count: 0, positions: [] } + this.measures.push(owned) + } + owned.count++ + } + if (!owned.positions) owned.positions = [] + if (position) { + owned.positions.push({ x: position.x, z: position.z }) + } else { + owned.positions.push(null as unknown as { x: number; z: number }) + } + + // Variablen updaten + this.recalcMeasureEffects() + + this.addEvent('build', `${m.emoji} ${m.name} gebaut (−${effectiveCost} Mio €)`, 'success') + this.notify() + return true + } + + /** + * Maßnahme abreißen: entfernt die ZULETZT gebaute Instanz dieser Maßnahme + * (inkl. ihrer Position), erstattet 50 % der Baukosten. Wartung und + * Effekte werden neu berechnet. + */ + demolishMeasure(measureId: string): boolean { + const m = MEASURES.find(x => x.id === measureId) + if (!m) return false + const owned = this.measures.find(x => x.measureId === measureId) + if (!owned || owned.count <= 0) return false + + owned.count-- + if (owned.positions && owned.positions.length > 0) { + owned.positions.pop() + } + if (owned.count === 0) { + this.measures = this.measures.filter(x => x.measureId !== measureId) + } + + const refund = Math.round(m.cost * this.researchDiscount * 0.5) + this.changeResource('budget', refund) + this.recalcMeasureEffects() + this.addEvent('demolish', `🗑 ${m.name} abgerissen (+${refund} Mio €)`, 'info') + this.notify() + return true + } + + /** + * Berechnet die aggregierten Effekte aller gebauten Maßnahmen neu. + * Wird bei buy/demolish UND einmal pro Tick aufgerufen, damit zeitabhängige + * Effekte (Sand-Erosion) korrekt fortlaufen. + */ + private recalcMeasureEffects(): void { + let totalCO2Red = 0 + let totalProtection = 0 + let totalUpkeep = 0 + let totalPower = 0 + for (const owned of this.measures) { + const m = MEASURES.find(x => x.id === owned.measureId) + if (!m) continue + totalCO2Red += m.co2Reduction * owned.count + totalUpkeep += m.upkeep * owned.count + totalPower += (m.powerOutput ?? 0) * owned.count + if (owned.measureId === 'sand-fill') { + // Sand-Aufschüttung erodiert: 2 cm pro Jahr, nach 9 Jahren weg + const ageYears = Math.max(0, this.tick - owned.builtAt) + const remaining = Math.max(0, m.protection - ageYears * 2) + totalProtection += remaining * owned.count + } else { + totalProtection += m.protection * owned.count + } + } + this.setVariable('co2Reduction', totalCO2Red) + this.setVariable('protection', totalProtection) + this.setVariable('upkeepTotal', totalUpkeep) + this.setResource('power', totalPower) + } + + getOwnedMeasures(): OwnedMeasure[] { + return this.measures + } + + getStartYear(): number { + return START_YEAR + } + + getCurrentYear(): number { + return START_YEAR + this.tick + } + + getMeasureCount(measureId: string): number { + return this.measures.find(x => x.measureId === measureId)?.count ?? 0 + } + + /** + * Voraussichtliche Jahres-Bilanz für die Status-Box. + * Liefert Einnahmen, Wartung, sonstige Klima-Folgen und Netto. + * Wird für die UI-Vorschau berechnet (nicht für die echte Tick-Rechnung). + */ + getYearlyBalance(): { income: number; tourism: number; upkeep: number; climate: number; net: number } { + const popRatio = this.getResource('population') / 10000 + const income = Math.round(this.diff.incomePer10k * popRatio) + const tourism = (this.tourismMode && this.seaLevelCm < 40) ? Math.round(35 * popRatio) : 0 + const upkeep = this.getVariable('upkeepTotal') + // Klima-Folgekosten (Versalzung etc.) — abhängig vom Schwierigkeitsgrad + let climate = 0 + if (this.seaLevelCm > 25) { + const severity = Math.min(1, (this.seaLevelCm - 25) / 40) + climate = Math.round(severity * 12 * this.diff.climateDamageMul) + } + const net = income + tourism - upkeep - climate + return { income, tourism, upkeep, climate, net } + } + + /** + * Hilfsmethode für Anzeige: Steuereinnahmen pro Jahr beim aktuellen Level. + */ + getIncomeAtCurrentLevel(): number { + const popRatio = this.getResource('population') / 10000 + return Math.round(this.diff.incomePer10k * popRatio) + } + + protected simulateTick(): void { + // 0. Maßnahmen-Effekte neu berechnen (für Sand-Erosion etc.) + this.recalcMeasureEffects() + + // 1. Steuereinnahmen (skaliert mit Bevölkerung, abhängig vom Schwierigkeitsgrad) + const popRatio = this.getResource('population') / 10000 + const income = Math.round(this.diff.incomePer10k * popRatio) + this.changeResource('budget', income) + + // 1b. Tourismus-Modus: solange der Strand existiert, bringt er Extra-Einnahmen + // UND zusätzliche CO₂-Belastung. Sobald der Strand vom Meer verschluckt + // wird (>40 cm), bricht der Tourismus zusammen → Bevölkerung wandert ab. + if (this.tourismMode) { + if (this.seaLevelCm < 40) { + // Strand noch da — Tourismus liefert + this.changeResource('budget', Math.round(35 * popRatio)) + this.co2Ppm += 0.6 + } else { + // Strand weg — Tourismus tot + this.changeResource('population', -Math.round(60 * popRatio)) + if (!this.eventFired('tourism-collapse')) { + this.addEvent( + 'tourism-collapse', + '🏖 Der Strand ist vom Meer verschluckt — der Tourismus bricht zusammen!', + 'danger', + 'sealevel', + ) + } + } + } + + // 2. Wartungskosten + const upkeep = this.getVariable('upkeepTotal') + this.changeResource('budget', -upkeep) + + // 3. CO₂ entwickelt sich (Emissionen steigen leicht über die Zeit) + const emissions = this.getEmissionsThisYear() + const reduction = this.getVariable('co2Reduction') + const netChange = emissions - reduction + this.co2Ppm = Math.max(KlimawaechterGame.CO2_FLOOR, this.co2Ppm + netChange) + + // 4. Klima neu berechnen + this.computeClimate() + + // 4b. STROM-BILANZ + // Bedarf = 1 MW pro 1.000 Einwohner. Kapazität = Σ powerOutput. + // Folgen + Schonfrist hängen vom Schwierigkeitsgrad ab. + const powerCapacity = this.getResource('power') + const powerDemand = Math.max(0, Math.round(this.getResource('population') / 1000)) + if (powerDemand > powerCapacity) { + this.blackoutStreak++ + if (!this.eventFired('blackout-first')) { + this.addEvent( + 'blackout-first', + '⚡ Achtung — die Insel hat zu wenig Strom! Baue jetzt ein Kraftwerk, sonst wird es bald kalt.', + 'warning', + 'blackout', + ) + } + // Folgen erst NACH der Schonfrist (Level 1: 3 Jahre, Level 2: 1, Level 3: 0) + if (this.blackoutStreak > this.diff.blackoutGrace) { + this.treesChoppedForHeat += this.diff.treesPerBlackout + this.co2Ppm += this.diff.co2BlackoutPerYear + } + // Eskalation: 2 Jahre nach Folgenbeginn auch Bevölkerungs-Abwanderung + const popLossThreshold = this.diff.blackoutGrace + 3 + if (this.blackoutStreak >= popLossThreshold) { + const drift = Math.round(25 * popRatio * this.diff.popLossMul * (this.blackoutStreak - popLossThreshold + 1)) + this.changeResource('population', -drift) + } + } else { + if (this.blackoutStreak > this.diff.blackoutGrace && !this.eventFired('blackout-recovered')) { + this.addEvent( + 'blackout-recovered', + '💡 Der Strom ist wieder da. Aber die gefällten Bäume kommen nicht zurück.', + 'info', + ) + } + this.blackoutStreak = 0 + } + + // 5. Schäden bei zu hoher Überflutung + if (this.floodedHouses > 0) { + // Bevölkerung sinkt langsam (mit Level-Mul) + const popLoss = Math.round(this.floodedHouses * 5 * this.diff.popLossMul) + this.changeResource('population', -popLoss) + } + + // 5b. Weitere Folgen des Meeresspiegelanstiegs auch OHNE Hausüberflutung. + // Schwere skaliert mit climateDamageMul / popLossMul des Schwierigkeitsgrads. + if (this.seaLevelCm > 25) { + const severity = Math.min(1, (this.seaLevelCm - 25) / 40) + // Entsalzung kostet Geld + this.changeResource('budget', -Math.round(severity * 12 * this.diff.climateDamageMul)) + // Langsamer Bevölkerungsrückgang durch schlechtere Lebensbedingungen + if (Math.random() < severity * this.diff.popLossMul) { + this.changeResource('population', -Math.round(severity * 15 * this.diff.popLossMul)) + } + } + + // 6. Events über die 75 Jahre verteilt + if (this.tick === 2 && !this.eventFired('model-hint')) { + this.addEvent( + 'model-hint', + '📚 Diese Insel steht stellvertretend für das gesamte Weltklima.', + 'info', + 'wedge', + ) + } + if (this.tick === 5 && !this.eventFired('warning-1')) { + this.addEvent('warning-1', '⚠️ Wissenschaftler warnen: CO₂ steigt weiter!', 'warning', 'co2') + } + if (this.tick === 15 && this.currentTemp > 15.5 && !this.eventFired('warning-temp1')) { + this.addEvent('warning-temp1', '🌡️ Die Temperatur ist um 0,5°C gestiegen.', 'warning', 'temperature') + } + if (this.tick === 25 && this.currentTemp > 16 && !this.eventFired('warning-temp2')) { + this.addEvent('warning-temp2', '🌡️ +1°C: Erste Hitzewellen werden häufiger.', 'warning', 'temperature') + } + if (this.tick === 40 && this.currentTemp > 17 && !this.eventFired('warning-temp3')) { + this.addEvent('warning-temp3', '🔥 +2°C: Pariser Klimaziel überschritten!', 'danger', 'temperature') + } + if (this.floodedHouses > 5 && this.floodedHouses < 10 && !this.eventFired('flood-1')) { + this.addEvent('flood-1', '🌊 Erste Häuser an der Küste sind betroffen.', 'warning', 'sealevel') + } + if (this.floodedHouses > 20 && !this.eventFired('flood-2')) { + this.addEvent('flood-2', '🌊 Massive Überflutung — viele Bewohner haben ihr Zuhause verloren!', 'danger', 'sealevel') + } + // Neue Folgen: Vegetation stirbt, Trinkwasser versalzt + if (this.seaLevelCm > 20 && !this.eventFired('vegetation-dying')) { + this.addEvent( + 'vegetation-dying', + '🌿 Mangroven und Küstenwälder sterben — Salzwasser versalzt die Böden.', + 'warning', + 'vegetation', + ) + } + if (this.seaLevelCm > 35 && !this.eventFired('drinking-water')) { + this.addEvent( + 'drinking-water', + '💧 Die Brunnen liefern immer mehr salziges Wasser — Trinkwasser wird knapp.', + 'danger', + 'drinking_water', + ) + } + if (this.currentTemp > 16.8 && !this.eventFired('glacier-melt')) { + this.addEvent( + 'glacier-melt', + '🏔 Der Gletscher am Vulkan schrumpft merklich.', + 'warning', + 'glacier', + ) + } + if (this.tick === 30 && this.getResource('budget') > 500 && !this.eventFired('praise-1')) { + this.addEvent('praise-1', '👏 Die Bevölkerung lobt deine kluge Haushaltsführung.', 'success') + } + if (this.tick === 50 && this.getVariable('co2Reduction') > 3 && !this.eventFired('praise-co2')) { + this.addEvent('praise-co2', '🌿 Deine Klimaschutzmaßnahmen zeigen Wirkung!', 'success') + } + if (this.tick === 60 && this.currentTemp < 16 && !this.eventFired('praise-temp')) { + this.addEvent('praise-temp', '🏆 Internationale Anerkennung für deine Klimapolitik!', 'success') + } + + // 7. Bürger-Beschwerden zu definierten Zeitpunkten + this.maybeTriggerCitizenEvent() + } + + /** + * Schaut, ob ein Bürger-Event passend zur Simulationslage gefeuert werden soll. + * Jedes Event nur einmal — `eventFired` verhindert Duplikate. + * + * Pro Schwierigkeitsgrad können Events ausgeblendet (skipCitizenEvents) und + * zeitlich verschoben (citizenEventTickOffset) werden. + */ + private maybeTriggerCitizenEvent(): void { + if (this.getPendingCitizenEvent()) return // schon eines offen + + // Hilfsfunktion: prüft alle Voraussetzungen (Tick, eventFired, Skip-Liste) + const offset = this.diff.citizenEventTickOffset || 0 + const skipped = new Set(this.diff.skipCitizenEvents || []) + const shouldFire = (id: string, baseTick: number, extraCondition: boolean = true): boolean => { + if (skipped.has(id)) return false + if (this.tick !== baseTick + offset) return false + if (this.eventFired(id)) return false + return extraCondition + } + + // === Lina, Fischerin === + if (shouldFire('citizen-fisher', 15)) { + this.triggerCitizenEvent({ + id: 'citizen-fisher', + character: '🎣', + title: 'Lina, die Fischerin', + message: 'Bürgermeister*in! Das Wasser wird wärmer, die Fische ziehen weg. Mein Boot ist alt — wir brauchen Hilfe oder wir müssen aufgeben.', + choices: [ + { + label: '120 Mio € für moderne Boote zahlen', + description: 'Bevölkerung bleibt stabil, Budget −120 Mio €', + apply: (g) => g.changeResource('budget', -120), + }, + { + label: 'Sich auf Tourismus umstellen lassen', + description: 'Kostet nichts. Bringt jährlich Extra-Einnahmen — solange der Strand bleibt. Aber: Mehr Touristen = mehr CO₂. Verschwindet der Strand, kollabiert die Wirtschaft.', + apply: (g) => { + g.changeResource('population', -200) + ;(g as KlimawaechterGame).tourismMode = true + g.addEvent( + 'tourism-on', + '🏖 Die Insel stellt sich auf Tourismus um — Hotels werden gebaut.', + 'info', + ) + }, + }, + { + label: 'Nichts tun', + description: '−400 Bevölkerung, dafür kein Geldverlust', + apply: (g) => g.changeResource('population', -400), + }, + ], + }) + } + + // === Dr. Hassan, Forscherin === + if (shouldFire('citizen-scientist', 9)) { + this.triggerCitizenEvent({ + id: 'citizen-scientist', + character: '👩‍🔬', + title: 'Dr. Hassan, Klimaforscherin', + message: 'Wir haben einen neuen Tonnen-Zähler entwickelt, der CO₂-Senken effizienter macht. Mit einem Forschungszentrum schaffen wir es, alle künftigen Maßnahmen günstiger zu bauen.', + choices: [ + { + label: 'Forschungszentrum bauen (300 Mio €)', + description: 'Alle künftigen Maßnahmen 20 % billiger', + apply: (g) => { + g.changeResource('budget', -300) + ;(g as KlimawaechterGame).researchDiscount = 0.8 + }, + }, + { + label: 'Antrag ablehnen', + description: 'Nichts passiert', + apply: () => {}, + }, + ], + }) + } + + // === Yusuf, Bauer === + if (shouldFire('citizen-farmer', 14, this.currentTemp > 15.8)) { + this.triggerCitizenEvent({ + id: 'citizen-farmer', + character: '👨‍🌾', + title: 'Yusuf, Bauer aus dem Süden', + message: 'Die Felder vertrocknen! Letztes Jahr hatten wir kaum Ernte. Wenn wir keine Bewässerung bekommen, müssen wir abwandern.', + choices: [ + { + label: 'Bewässerungssystem bauen (180 Mio €)', + description: 'Bevölkerung stabil', + apply: (g) => g.changeResource('budget', -180), + }, + { + label: 'Salzresistente Sorten subventionieren (60 Mio €)', + description: '−100 Menschen, aber langfristig stabiler', + apply: (g) => { + g.changeResource('budget', -60) + g.changeResource('population', -100) + }, + }, + { + label: 'Nichts tun', + description: '−500 Menschen ziehen weg', + apply: (g) => g.changeResource('population', -500), + }, + ], + }) + } + + // === Maria, Hotel === + if (shouldFire('citizen-tourism', 20, this.seaLevelCm > 15)) { + this.triggerCitizenEvent({ + id: 'citizen-tourism', + character: '🏖️', + title: 'Maria, Hotelbesitzerin', + message: 'Der Strand wird immer kleiner. Touristen bleiben weg, meine Hotels stehen halbleer. Wir müssen etwas gegen den Meeresspiegel tun!', + choices: [ + { + label: 'Strand künstlich aufschütten (50 Mio €)', + description: 'Einmaliger Effekt: +5 cm Schutz', + apply: (g) => { + g.changeResource('budget', -50) + const cur = g.getVariable('protection') + ;(g as any).variables['protection'] = cur + 5 + }, + }, + { + label: '"Wir können das Meer nicht aufhalten."', + description: '−300 Menschen wandern ab', + apply: (g) => g.changeResource('population', -300), + }, + ], + }) + } + + // === Lia, 14 Jahre === + if (shouldFire('citizen-youth', 28, this.getVariable('co2Reduction') < 1)) { + this.triggerCitizenEvent({ + id: 'citizen-youth', + character: '👧', + title: 'Lia, 14 Jahre', + message: 'Sie planen unsere Zukunft! Wir haben heute gestreikt. Wir wollen, dass Sie endlich ernst machen mit dem Klimaschutz — sonst werden wir weiterstreiken.', + choices: [ + { + label: '"Ich verspreche, mehr zu tun" — sofort 1 Solar gratis', + description: '+1 kostenlose Solaranlage', + apply: (g) => { + const sub = g as KlimawaechterGame + const existing = sub['measures'].find((m: any) => m.measureId === 'solar') + if (existing) existing.count++ + else sub['measures'].push({ measureId: 'solar', builtAt: sub['tick'], count: 1 }) + ;(sub as any).recalcMeasureEffects() + }, + }, + { + label: 'Streik aussitzen', + description: '−150 Bevölkerung (Jugend wandert ab)', + apply: (g) => g.changeResource('population', -150), + }, + ], + }) + } + + // === Konzernchef Vogel === + if (shouldFire('citizen-industry', 38)) { + this.triggerCitizenEvent({ + id: 'citizen-industry', + character: '🏭', + title: 'Konzernchef Vogel', + message: 'Wir bringen Arbeitsplätze auf die Insel — wenn Sie uns Steuererleichterung geben. Sonst müssen wir abwandern.', + choices: [ + { + label: 'Subventionen zahlen (250 Mio €)', + description: '+500 Bevölkerung, +5 ppm CO₂ einmalig', + apply: (g) => { + g.changeResource('budget', -250) + g.changeResource('population', 500) + const sub = g as KlimawaechterGame + sub['co2Ppm'] += 5 + }, + }, + { + label: 'Klar nein', + description: '−200 Bevölkerung verlässt die Insel', + apply: (g) => g.changeResource('population', -200), + }, + { + label: 'Bedingung: Nur wenn klimaneutral', + description: 'Konzern lehnt ab, nichts passiert', + apply: () => {}, + }, + ], + }) + } + + // === Anna, Bergdorf === + if (shouldFire('citizen-mountain', 50, this.currentTemp > 16.5)) { + this.triggerCitizenEvent({ + id: 'citizen-mountain', + character: '🏔', + title: 'Anna vom Bergdorf', + message: 'Der Gletscher am Vulkan schmilzt rasant. Bei Starkregen fließt das Wasser in unser Dorf. Wir brauchen Schutzwälle!', + choices: [ + { + label: 'Lawinen-Schutzwall bauen (55 Mio €)', + description: '+4 cm Schutz, Dorf bleibt sicher', + apply: (g) => { + g.changeResource('budget', -55) + const cur = g.getVariable('protection') + ;(g as any).variables['protection'] = cur + 4 + ;(g as KlimawaechterGame).hasMountainShield = true + }, + }, + { + label: 'Bergdorf evakuieren', + description: '−400 Menschen, aber Geld gespart', + apply: (g) => g.changeResource('population', -400), + }, + ], + }) + } + } + + private firedEvents = new Set() + private eventFired(id: string): boolean { + if (this.firedEvents.has(id)) return true + this.firedEvents.add(id) + return false + } + + protected checkLossCondition(): boolean { + if (this.getResource('budget') < -500) return true + if (this.floodedHouses > 50) return true + return false + } + + // === Subclass-State persistieren === + protected serializeSubclass(): Record { + return { + co2Ppm: this.co2Ppm, + currentTemp: this.currentTemp, + targetTemp: this.targetTemp, + seaLevelCm: this.seaLevelCm, + committedSeaCm: this.committedSeaCm, + floodedHouses: this.floodedHouses, + researchDiscount: this.researchDiscount, + tourismMode: this.tourismMode, + hasMountainShield: this.hasMountainShield, + treesChoppedForHeat: this.treesChoppedForHeat, + blackoutStreak: this.blackoutStreak, + difficulty: this.diff.id, + measures: this.measures, + firedEvents: Array.from(this.firedEvents), + } + } + + protected deserializeSubclass(data: Record): void { + if (typeof data.co2Ppm === 'number') this.co2Ppm = data.co2Ppm + if (typeof data.currentTemp === 'number') this.currentTemp = data.currentTemp + if (typeof data.targetTemp === 'number') this.targetTemp = data.targetTemp + if (typeof data.seaLevelCm === 'number') this.seaLevelCm = data.seaLevelCm + if (typeof data.committedSeaCm === 'number') this.committedSeaCm = data.committedSeaCm + if (typeof data.floodedHouses === 'number') this.floodedHouses = data.floodedHouses + if (typeof data.researchDiscount === 'number') this.researchDiscount = data.researchDiscount + if (typeof data.tourismMode === 'boolean') this.tourismMode = data.tourismMode + if (typeof data.hasMountainShield === 'boolean') this.hasMountainShield = data.hasMountainShield + if (typeof data.treesChoppedForHeat === 'number') this.treesChoppedForHeat = data.treesChoppedForHeat + if (typeof data.blackoutStreak === 'number') this.blackoutStreak = data.blackoutStreak + if (data.difficulty === 1 || data.difficulty === 2 || data.difficulty === 3) { + this.diff = DIFFICULTY_LEVELS[data.difficulty] + } + if (Array.isArray(data.measures)) this.measures = data.measures as OwnedMeasure[] + if (Array.isArray(data.firedEvents)) this.firedEvents = new Set(data.firedEvents as string[]) + // Variablen aus den Maßnahmen neu berechnen (upkeep, co2Reduction, protection) + this.recalcMeasureEffects() + } +} diff --git a/App/src/sims/sim-05-treibhaus/logic.ts b/App/src/sims/sim-05-treibhaus/logic.ts new file mode 100644 index 0000000..d8921cc --- /dev/null +++ b/App/src/sims/sim-05-treibhaus/logic.ts @@ -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() + } +} diff --git a/App/src/sims/sim-05-treibhaus/renderer.ts b/App/src/sims/sim-05-treibhaus/renderer.ts new file mode 100644 index 0000000..7ea81a9 --- /dev/null +++ b/App/src/sims/sim-05-treibhaus/renderer.ts @@ -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) + } +} diff --git a/App/src/sims/sim-07-erdbeben/game-renderer.ts b/App/src/sims/sim-07-erdbeben/game-renderer.ts new file mode 100644 index 0000000..0d59503 --- /dev/null +++ b/App/src/sims/sim-07-erdbeben/game-renderer.ts @@ -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() + 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 = {} + + 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() + } + } +} diff --git a/App/src/sims/sim-07-erdbeben/game.ts b/App/src/sims/sim-07-erdbeben/game.ts new file mode 100644 index 0000000..2ca75e2 --- /dev/null +++ b/App/src/sims/sim-07-erdbeben/game.ts @@ -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() + + 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 + } +} diff --git a/App/src/sims/sim-07-erdbeben/logic.ts b/App/src/sims/sim-07-erdbeben/logic.ts new file mode 100644 index 0000000..2e53ad7 --- /dev/null +++ b/App/src/sims/sim-07-erdbeben/logic.ts @@ -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 {} +} diff --git a/App/src/sims/sim-07-erdbeben/renderer.ts b/App/src/sims/sim-07-erdbeben/renderer.ts new file mode 100644 index 0000000..939e578 --- /dev/null +++ b/App/src/sims/sim-07-erdbeben/renderer.ts @@ -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 | 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) + } + } + } + } +} diff --git a/App/src/sims/sim-08-energiemix/game-renderer.ts b/App/src/sims/sim-08-energiemix/game-renderer.ts new file mode 100644 index 0000000..01edcfa --- /dev/null +++ b/App/src/sims/sim-08-energiemix/game-renderer.ts @@ -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() + 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' diff --git a/App/src/sims/sim-08-energiemix/game.ts b/App/src/sims/sim-08-energiemix/game.ts new file mode 100644 index 0000000..7591b9e --- /dev/null +++ b/App/src/sims/sim-08-energiemix/game.ts @@ -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() + + // 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 + } +} diff --git a/App/src/sims/sim-09-energiemix/logic.ts b/App/src/sims/sim-09-energiemix/logic.ts new file mode 100644 index 0000000..f46c8da --- /dev/null +++ b/App/src/sims/sim-09-energiemix/logic.ts @@ -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): 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 = {} + 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 = {} + 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 + } + + protected onVariableChange(): void { + this.compute() + } +} diff --git a/App/src/sims/sim-09-energiemix/renderer.ts b/App/src/sims/sim-09-energiemix/renderer.ts new file mode 100644 index 0000000..0ba8951 --- /dev/null +++ b/App/src/sims/sim-09-energiemix/renderer.ts @@ -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 = {} + 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): 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' + } +} diff --git a/App/src/sims/sim-10-lieferketten/game.ts b/App/src/sims/sim-10-lieferketten/game.ts new file mode 100644 index 0000000..d99359e --- /dev/null +++ b/App/src/sims/sim-10-lieferketten/game.ts @@ -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> = { + 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 = { + 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 = {} + /** Bisher gefeuerte Events */ + private firedEvents = new Set() + + constructor(config: Partial = {}) { + 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 { + return { + config: this.config, + history: this.history, + activeEffects: this.activeEffects, + firedEvents: Array.from(this.firedEvents), + } + } + + protected deserializeSubclass(data: Record): 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 + if (Array.isArray(data.firedEvents)) this.firedEvents = new Set(data.firedEvents as string[]) + } +} diff --git a/App/src/sims/sim-10-lieferketten/renderer.ts b/App/src/sims/sim-10-lieferketten/renderer.ts new file mode 100644 index 0000000..b3a4504 --- /dev/null +++ b/App/src/sims/sim-10-lieferketten/renderer.ts @@ -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 = {} + 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): 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 . + * Wir zeichnen 4 grobe Kontinent-Blobs — keine Geo-Genauigkeit, nur damit + * die Marker im richtigen Bereich sitzen. + */ + private drawWorldShape(): string { + const oceanGrad = ` + + + + + + + ` + // Kontinent-Pfade — handgemalt, didaktische Vereinfachung + const continents = ` + + + + + + + + + + + + + + + ` + return `${oceanGrad}${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 `` + } + + 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 ` + + + + ${emoji} + ${this.escape(label)} + + ` + } + + 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, '>') + } +} diff --git a/App/src/sims/sim-11-regenwald/data.ts b/App/src/sims/sim-11-regenwald/data.ts new file mode 100644 index 0000000..d3dc38a --- /dev/null +++ b/App/src/sims/sim-11-regenwald/data.ts @@ -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.', + }, +] diff --git a/App/src/sims/sim-12-fluss/game.ts b/App/src/sims/sim-12-fluss/game.ts new file mode 100644 index 0000000..b30b4ea --- /dev/null +++ b/App/src/sims/sim-12-fluss/game.ts @@ -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 + } + } +} diff --git a/App/src/sims/sim-12-fluss/logic.ts b/App/src/sims/sim-12-fluss/logic.ts new file mode 100644 index 0000000..808ccb6 --- /dev/null +++ b/App/src/sims/sim-12-fluss/logic.ts @@ -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 + scoreWeights: { safety: number; ecology: number; agriculture: number; economy: number } + events?: LevelEvent[] + winConditions: { minScore?: number; targetScores?: Partial } + 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 = { + 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 = { + 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 = { + 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' }, +} diff --git a/App/src/sims/sim-12-fluss/renderer.ts b/App/src/sims/sim-12-fluss/renderer.ts new file mode 100644 index 0000000..0a6ecb4 --- /dev/null +++ b/App/src/sims/sim-12-fluss/renderer.ts @@ -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() + } + } +} diff --git a/App/src/styles/base.css b/App/src/styles/base.css new file mode 100644 index 0000000..4fe7b28 --- /dev/null +++ b/App/src/styles/base.css @@ -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); +} diff --git a/App/src/styles/tokens.css b/App/src/styles/tokens.css new file mode 100644 index 0000000..53c7005 --- /dev/null +++ b/App/src/styles/tokens.css @@ -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; +} diff --git a/App/src/ui/animations/scenic-bg.ts b/App/src/ui/animations/scenic-bg.ts new file mode 100644 index 0000000..0a00b0c --- /dev/null +++ b/App/src/ui/animations/scenic-bg.ts @@ -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 + } +} diff --git a/App/src/ui/components/sim-shell.ts b/App/src/ui/components/sim-shell.ts new file mode 100644 index 0000000..0e5d500 --- /dev/null +++ b/App/src/ui/components/sim-shell.ts @@ -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 ` + + +
    +

    ${m.name}

    +
    + ${localName} + ⏱ ${m.dpiMinuten} min + ${m.typ} +
    +
    + +
    +
    📖 Intro
    +
    🤔 Predict
    +
    🔬 Simulate
    +
    👁️ Observe
    +
    💭 Reflect
    +
    + +
    +
    +
    +
    +

    Parameter

    +
    +
    +

    🎯 Lernziele

    +
      ${m.lernziele.map(l => `
    • ${l}
    • `).join('')}
    +
    + +
    +
    + ` + + // 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 = ` + + + ` + + 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') + }) + } +} diff --git a/App/src/ui/game-ui.ts b/App/src/ui/game-ui.ts new file mode 100644 index 0000000..81f8b28 --- /dev/null +++ b/App/src/ui/game-ui.ts @@ -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 = {} + 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('[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) => ` + + `).join('') + host.innerHTML = ` +
    +
    +
    +
    ${ev.character}
    +
    +
    ${ev.title}
    +
    Eine Bürgerin meldet sich
    +
    +
    +
    „${ev.message}"
    +
    ${choicesHtml}
    +
    +
    + ` + host.querySelectorAll('.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('.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 = ` +
    +
    + ⚡ Strom +
    + 0 +
    +
    + 🏠 Bedarf +
    + 0 +
    +
    Versorgung gesichert
    +
    + ` + } else if (r.id === 'budget' && typeof (this.game as any).getYearlyBalance === 'function') { + // Spezialfall BUDGET: Jahres-Bilanz (Einnahmen / Wartung / Netto) + extra = ` +
    +
    + + Steuern + 0 +
    + +
    + − Wartung + 0 +
    + +
    + = pro Jahr + 0 +
    +
    + ` + } + return ` +
    +
    ${r.icon}
    +
    +
    ${r.name}
    +
    + ${extra} +
    +
    + `}).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(`[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('[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(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('[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('.pg-cap') + const demFill = host.querySelector('.pg-dem') + const capNum = host.querySelector('.pg-cap-num') + const demNum = host.querySelector('.pg-dem-num') + const status = host.querySelector('.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(`[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 ` +
    +
    +
    ${g.achieved ? '✓' : ''}
    +
    ${goal.title || g.id}
    +
    +
    +
    + ` + }).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 => `${b.label}`).join('') + const ownedBadge = owned > 0 + ? `×${owned}` + : '' + const demolishBtn = (owned > 0 && canDemolish) + ? `` + : '' + return ` +
    +
    ${item.emoji}
    +
    +
    ${item.name} ${ownedBadge}
    +
    ${item.description}
    +
    + 💰 ${item.cost} Mio € + ${badgesHtml} + ${item.upkeep ? `⚙ ${item.upkeep} Mio €/J` : ''} +
    +
    + ${demolishBtn} +
    + ` + }).join('') + + el.querySelectorAll('.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('.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 = '
    Noch keine Ereignisse
    ' + 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] + ? `` + : '' + return ` +
    +
    ${prefix}: ${e.text}
    + ${infoBtn} +
    + ` + }).join('') + el.querySelectorAll('.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 += `` + if (z.label) { + bgRects += `${this.escapeXml(z.label)}` + } + } + } + + // === 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 += `${this.escapeXml(labelText)}` + // Dezenter Gitterstrich + yAxis += `` + } + + // === 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 += `` + if (l.label) { + refLines += `${this.escapeXml(l.label)}` + } + } + } + + // === 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 = `` + } + 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 += ` + + + + + ` + linePath += `` + // 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 = ` + + ${this.escapeXml(valText)} + ` + } + + 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, ''') + } + + 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. Das ist kein Sieg.` + } 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 => ` +
    + ${g.achieved ? '✓' : '✗'} + ${g.title} +
    + `).join('') + + // Resourcen + const statsHtml = this.game.getResourcesArray().slice(0, 4).map(r => ` +
    +
    ${r.format ? r.format(r.current) : r.current}
    +
    ${r.name}
    +
    + `).join('') + + const html = ` +
    +
    +
    ${icon}
    +

    ${title}

    +

    ${summary}

    +
    ${goalsHtml}
    +
    ${statsHtml}
    + +
    +
    + ` + 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) + } +} diff --git a/App/src/ui/info-overlay.ts b/App/src/ui/info-overlay.ts new file mode 100644 index 0000000..583a6f2 --- /dev/null +++ b/App/src/ui/info-overlay.ts @@ -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
    . + * 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 = { + ppm: { + title: 'ppm', + text: '„parts per million" – wie viele CO₂-Teilchen unter 1 Million Luft-Teilchen sind. 425 ppm = 425 CO₂-Teilchen unter einer Million. Früher waren es 280, ab 500 wird es gefährlich.', + }, + celsius: { + title: '°C', + text: 'Grad Celsius. Die globale Durchschnitts­temperatur der Erde. Vorindustriell: 15 °C. Pariser Klimaziel: unter 17 °C halten.', + }, + paris_goal: { + title: 'Pariser Klimaziel', + text: '2015 haben fast alle Länder der Welt in Paris versprochen, die Erderwärmung auf höchstens 2 °C über dem vorindustriellen Niveau zu begrenzen — besser sogar auf 1,5 °C. In unserer Simulation heißt das: Die globale Temperatur darf nicht über 17 °C steigen.', + }, + cm: { + title: 'cm Meeresspiegel', + text: 'Zentimeter, um die das Meer seit Start angestiegen ist. 30 cm = Strand weg. 60 cm = erste Häuser betroffen.', + }, + mio_euro: { + title: 'Mio €', + text: 'Millionen Euro. So viel Geld hat der ganze Inselstaat zur Verfügung. 1 Mio € = 1.000.000 €.', + }, + population: { + title: 'Bevölkerung', + text: 'Wie viele Menschen auf der Insel leben. Mehr Leute = mehr Steuern, aber auch mehr zu schützen.', + }, + budget: { + title: 'Budget', + text: 'Dein Geld. Steigt durch Steuern (Bevölkerung), sinkt durch Baukosten und Wartung. Unter 0 = Pleite.', + }, + flooded: { + title: 'Überflutete Gebiete', + text: 'Wie viel Prozent deiner Stadt schon unter Wasser stehen. Ab 30% drohst du zu verlieren.', + }, + upkeep: { + title: 'Wartung', + text: 'Jedes Jahr abgezogene Kosten, um eine Maßnahme (Solar, Wind, Deich …) am Laufen zu halten.', + }, + power: { + title: 'Strom (MW)', + text: 'Megawatt – die Einheit für elektrische Leistung. Eine Insel mit 6.000 Einwohnern braucht etwa 6 MW. Die Anzeige „6/8 MW" heißt: 6 MW werden gebraucht, 8 MW sind da. Wenn der Bedarf größer wird als die Kapazität, hast du einen Stromausfall.', + }, + blackout: { + title: 'Stromausfall', + text: 'Wenn deine Kraftwerke nicht genug Strom liefern, wird es kalt. Die Leute fällen Bäume und verheizen das Holz, um warm zu bleiben. Das setzt zusätzliches CO₂ frei — und tote Bäume können kein CO₂ mehr binden. Ein doppelter Schaden! Baue rechtzeitig genug Kraftwerke (Wind oder Solar — Kohle ist billiger, aber schlecht für das Klima).', + }, +} + +let tipEl: HTMLDivElement | null = null +let tipInstalled = false + +function ensureTipElement(): HTMLDivElement { + if (tipEl) return tipEl + tipEl = document.createElement('div') + tipEl.className = 'info-tip' + tipEl.style.cssText = ` + position: fixed; pointer-events: none; z-index: 95; + max-width: 260px; padding: .55rem .75rem; + background: #1a2b33; color: #f5f6f4; + border-radius: 8px; font-size: .78rem; line-height: 1.45; + box-shadow: 0 10px 30px rgba(0,0,0,.35); + opacity: 0; transition: opacity .12s ease-out; + font-family: inherit; + ` + document.body.appendChild(tipEl) + return tipEl +} + +function showTip(key: string, x: number, y: number): void { + const tip = TIPS[key] + if (!tip) return + const el = ensureTipElement() + el.innerHTML = ` +
    ${escapeHtml(tip.title)}
    +
    ${escapeHtml(tip.text)}
    + ` + // 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 = { + 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
    + * 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 => `

    ${escapeHtml(p)}

    `).join('') + const hintHtml = topic.hint + ? `
    💡 ${escapeHtml(topic.hint)}
    ` + : '' + host.innerHTML = ` +
    +
    +

    ${escapeHtml(topic.title)}

    + ${paragraphs} + ${hintHtml} + +
    +
    + ` + 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, '"') +} diff --git a/App/stadt.html b/App/stadt.html new file mode 100644 index 0000000..d7aad11 --- /dev/null +++ b/App/stadt.html @@ -0,0 +1,450 @@ + + + + + + GeoGraSim — Stadt & Raumplanung + + + + + + +
    +
    +
    Kennwerte
    +
    Verlauf
    +
    +
    +
    +
    💰 0
    + 1/10 + +
    +
    +
    +
    +
    Bauen
    +
    Nachrichten
    +
    +
    +
    +

    🏘️ Stadt & Raumplanung

    +

    Baue eine Siedlung auf isometrischen Kacheln. Jede Kachel hat Straßen eingebaut. Achte darauf, dass sie zusammenpassen!

    + +
    + + + + + diff --git a/App/stilauswahl.html b/App/stilauswahl.html new file mode 100644 index 0000000..cfe76d7 --- /dev/null +++ b/App/stilauswahl.html @@ -0,0 +1,215 @@ + + + + + +GeoGraSim — Alle 40 Stilvarianten + + + + + +
    +

    🎨 Alle Stilvarianten

    +

    Welcher visuelle Stil passt am besten für ein Geografie-Lernprogramm für Jugendliche (10–14)?

    +
    50 Stile · Klicke deine Favoriten
    +
    + +
    + + + + + + +
    + + +

    ☀️ Helle Stile (16)

    +
    + +

    A Isometric Low-Poly

    Geometrische 3D-Blöcke, Pastellfarben, Monument Valley trifft Schulbuch.

    ✓ Spielerisch✓ Einzigartig✗ Zu „gamig"?
    + +

    B Flat Vector

    Kräftige Farben, klare Outlines. Notion/Stripe-Ästhetik.

    ✓ Modern✓ Skalierbar✗ Nüchtern
    + +

    D Paper Cut-Out

    Geschichtete Papierlagen, weiche Pastelltöne, taktil. Pop-Up-Buch.

    ✓ Warm✓ Inklusiv✗ Zu jung?
    + +

    G 3D Clay / Claymation

    Knete-Formen, runde Ecken, warme Beleuchtung. Apple-Ästhetik.

    ✓ Zugänglich✓ Trend 2026✗ Aufwändig
    + +

    H Minimal Line Art

    Feine schwarze Linien, ein Akzentton, viel Weißraum. Infografik-Charakter.

    ✓ Elegant✓ Barrierefrei✗ Zu erwachsen?
    + +

    I Soft Gradient / Bento

    Sanfte Verläufe, Apple Weather, luftig und leicht.

    ✓ Freundlich✓ Modern 2026✓ Lesbar
    + +

    J Scandinavian Minimal

    Weißraum, gedeckte Naturfarben, ruhig und vertrauenswürdig.

    ✓ Beruhigend✓ Zeitlos✓ Inklusiv
    + +

    K Friendly 3D Pastel

    Weiche 3D-Pastellformen, Headspace/Duolingo 2026.

    ✓ Positiv✓ Trend✓ Warm
    + +

    L Doodle / Sketch

    Handgezeichnet, Whiteboard-Notizen, nahbar und authentisch.

    ✓ Nahbar✓ Günstig✗ Zu einfach?
    + +

    M Geometric Bauhaus

    Klare Formen, helle Farben, Mondrian trifft Schulatlas.

    ✓ Strukturiert✓ Bildend✗ Zu abstrakt?
    + +

    N Botanical / Nature Journal

    Feine Linien, natürliche Farben, wie ein modernes Naturkundeheft.

    ✓ Wissenschaftlich✓ Edel✗ Nicht „digital"
    + +

    O Glasmorphism Light

    Milchglas-Panels über Gradient. Premium, 2026.

    ✓ Sehr modern✓ Premium✗ Lesbarkeit
    + +

    P Neubrutalism

    Dicke schwarze Rahmen, rohe Formen, kräftige Farben. Frech.

    ✓ Auffällig✓ Trendy✗ Polarisierend
    + +

    X Origami Papier

    Gefaltete Papierlandschaften, präzise Knicke, sanfte Schatten.

    ✓ Einzigartig✓ Taktil✗ Aufwändig
    + +

    R Pixel Art 16-bit

    Retro-Gaming SNES. Charmant, nostalgisch, fröhlich.

    ✓ Spielerisch✓ Günstig✗ Nicht ernst genug?
    + +

    AA Infographic Flat

    Dashboard-Ästhetik: Weltkarte mit Datenpunkten und Charts.

    ✓ Daten-affin✓ Klar✗ Kalt
    + +
    + + +

    🌙 Dunkle Stile (5)

    +
    + +

    E Neon Glassmorphism

    Dunkler Grund, leuchtende Neon-Linien, Gaming-Generation.

    ✓ Cool✓ Energetisch✗ Dunkel
    + +

    S Blueprint Technical

    Weiße Linien auf tiefem Blau. Ingenieur-Ästhetik.

    ✓ Wissenschaftlich✓ Einzigartig✗ Einschüchternd
    + +

    W Chalkboard / Tafel

    Kreide auf dunkelgrüner Tafel. Nostalgisch, schulisch.

    ✓ Vertraut✗ „Alte Schule"✗ Lesbarkeit
    + +

    AD Cyberpunk Holographic

    Holografisch-irideszent, Glitch, Neon. Futuristisch.

    ✓ Wow-Faktor✗ Dunkel✗ Ablenkend
    + +

    Z Satelliten-Realismus

    NASA-Fotografie. Die Erde aus dem All. Ehrfurchtgebietend.

    ✓ Real✓ Beeindruckend✗ Kein eigener Stil
    + +
    + + +

    🎭 Künstlerische Stile (13)

    +
    + +

    C Retro-Futurismus / Risograph

    Körnige Texturen, 3–4 Farben, cooles Wissenschaftsposter.

    ✓ Cool-Faktor✓ Wiedererkennbar✗ „Alt"?
    + +

    F Aquarell / Watercolor

    Weiche Texturen, natürliche Farben, Naturkunde-Ästhetik.

    ✓ Einzigartig✓ Beruhigend✗ Wenig „digital"
    + +

    Q Japanischer Holzschnitt

    Hokusai-inspiriert: Wellen, Berge, traditionelle Palette.

    ✓ Einzigartig✓ Kulturbezug✗ Aneignung?
    + +

    T Collage / Mixed Media

    Gerissenes Papier, Foto-Fragmente, Washi-Tape. Projektboard.

    ✓ Kreativ✗ Unruhig✗ Inkonsistent
    + +

    U Gradient Mesh

    Fließende Farbverläufe, keine harten Kanten. Abstrakt, meditativ.

    ✓ Modern✗ Wenig konkret
    + +

    V Buntglas / Stained Glass

    Schwarze Bleilinien, Juwelenfarben. Mittelalter trifft Modern.

    ✓ Dekorativ✗ Schwer für UI
    + +

    Y Linolschnitt / Block Print

    Kräftig geschnitzte Formen, 2–3 Farben, handgemacht.

    ✓ Markant✗ Zu grob für Details
    + +

    AB Art Nouveau / Jugendstil

    Fließende Linien, dekorative Rahmen, Mucha-Ästhetik.

    ✓ Elegant✗ Aufwändig✗ „Alt"?
    + +

    AC Memphis Design (80er)

    Kräftige Geometrie, Squiggly Lines, Terrazzo. Retro-cool.

    ✓ Energetisch✗ Visuell laut
    + +

    AE Gouache Poster Paint

    Dicke Pinselstriche, lebendige matte Farben. Handgemaltes Reiseposter.

    ✓ Warm✓ Künstlerisch✓ Einladend
    + +

    AF Topografische Karte

    Höhenlinien, Elevation-Farben. Kartografie trifft Kunst.

    ✓ Geo-DNA✓ Elegant
    + +

    AG Halftone / Rasterdruck

    Zeitungsdruck: Punktraster, CMYK-Versatz, editorial.

    ✓ Distinktiv✗ Schwer lesbar klein
    + +

    AH Studio Ghibli / Anime

    Saftige Hügel, dramatische Wolken, rote Dächer. Magisch und warm.

    ✓ Emotional✓ Beliebt✗ Lizenz-Assoziation
    + +
    + + +

    🧪 Wild & Ungewöhnlich (6)

    +
    + +

    AI Unterwasser-Perspektive

    Blick von UNTER der Wasseroberfläche nach oben. Küstenlinie durch Wasser verzerrt, Lichtbrechung, Korallen im Vordergrund. Niemand macht das.

    ✓ Völlig einzigartig✓ Immersiv✗ Schwer für alle Themen
    + +

    AJ Röntgen / X-Ray

    Die Erde wie ein medizinischer Scan: transluzente Schichten, Städte als leuchtende Knoten, biolumineszente Ästhetik. Die Welt durchleuchtet.

    ✓ Wissenschafts-Wow✓ Surreal✗ Dunkel
    + +

    AK Stickerei / Embroidery

    Weltkarte gestickt auf Leinen: Wälder als Knötchenstich, Ozeane als Plattstich, Berge als Kettenstich. Sichtbare Stofftextur, Fadenwerk. Wie Omas schönstes Stickbild.

    ✓ Überwältigend einzigartig✓ Warm✓ Handwerk
    + +

    AL Daten-Regen / Data Rain

    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.

    ✓ Technik + Geo✓ Einzigartig✗ Abstrakt
    + +

    AM Kinderzeichnung Deluxe

    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.

    ✓ Entwaffnend✓ Angstfrei✓ Einzigartig✗ Nicht ernst genug?
    + +

    AN Thermografie / Wärmebild

    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.

    ✓ Wissenschaftlich✓ Faszinierend✗ Dunkel✗ Einseitig
    + +
    + + +

    🧶 Handarbeit & Haptik (10)

    +
    + +

    AO Zerrissene Papierfetzen

    Landschaft aus gerissenen Papierschnipseln auf weißem Grund. Sichtbare Risskanten, Klebereste, überlappende Lagen. Roh und ehrlich.

    ✓ Roh-authentisch✓ Taktil✗ Unordentlich?
    + +

    AP Washi Tape Landkarte

    Weltkarte aus bunten Washi-Tape-Streifen: Blumen-, Streifen-, Punkt-Muster. Tape-Kanten sichtbar. Bullet-Journal-Ästhetik.

    ✓ Zielgruppen-nah✓ Hübsch✓ Hell
    + +

    AQ Filz & Stoff Applikation

    Berge aus grauem Wollfilz, Bäume aus grünen Filzkreisen, Fluss aus blauem Satinband. Sichtbare Nähte. Wie ein Quiet Book.

    ✓ Warm✓ Inklusiv✓ Einzigartig
    + +

    AR Gepresste Blumen & Botanik

    Landschaft aus echten gepressten Blumen und Blättern: Farne als Berge, Lavendel als Himmel, Gräser als Felder. Herbarium-Ästhetik.

    ✓ Museumsreif✓ Naturverbunden✗ Sehr speziell
    + +

    AS Buntstift auf Packpapier

    Landschaft mit Buntstiften auf Packpapier gezeichnet. Sichtbare Striche, warmer Braunton scheint durch. Ehrlich wie ein Schülerheft.

    ✓ Nahbar✓ Warm✓ Günstig
    + +

    AT Kartoffelstempel

    Landschaft aus gestempelten Formen: Kreise, Dreiecke, Quadrate. Ungleichmäßige Farbe, charmante Unperfektheit. Volksschul-Energie.

    ✓ Spielerisch✓ Fröhlich✗ Zu kindlich?
    + +

    AU Pappmaché Relief

    3D-Reliefkarte aus Pappmaché, von oben fotografiert. Echte Schatten, Zeitungstext scheint durch die Farbe. Werkstatt-Ästhetik.

    ✓ Dreidimensional✓ Taktil✗ Aufwändig
    + +

    AV Scherenschnitt

    Filigrane Silhouette aus einem Blatt geschnitten: Berge, Bäume, Häuser, Vögel — alles verbunden. Schweizer/deutsche Volkskunst-Tradition.

    ✓ Elegant✓ Kulturell✓ Wiedererkennbar
    + +

    AW Aquarell-Skizzenbuch

    Offenes Skizzenbuch mit schnellen Aquarell-Feldskizzen. Bleistift-Unterzeichnung sichtbar, Wasserflecken am Rand. Authentisches Feldtagebuch.

    ✓ Authentisch✓ Wissenschaftlich✓ Warm
    + +

    AX Makramee & Textilkunst

    Landschaft aus Makramee-Knoten und Webkunst. Berge aus Knotenmuster, Ozean aus blauer Garnfranse, Sonne aus goldenem Webkreis. Boho-Handwerk.

    ✓ Textur-reich✓ Modern-Handwerk✗ Nische
    + +
    + +
    +

    💡 Mein Ranking für Jugendliche (10–14):

    +

    🥇 AH (Ghibli) — emotional, warm, bei der Zielgruppe extrem beliebt
    +🥈 AK (Stickerei) — überwältigend einzigartig, warm, niemand hat sowas
    +🥉 AE (Gouache) — handgemalt, einladend, hebt sich komplett ab
    +4. J (Scandinavian) — zeitlos, inklusiv, unsere aktuelle Wahl für die UI
    +5. AF (Topografisch) — fachlich perfekt, Geo-DNA pur
    +6. AM (Kinderzeichnung) — der mutigste Move. Maximal angstfrei.

    +

    Stile lassen sich kombinieren: z.B. Skandinavische UI + Ghibli-Themenbilder + Infografik-Daten.

    +
    + + + + diff --git a/App/tests/unit/education-levels.test.ts b/App/tests/unit/education-levels.test.ts new file mode 100644 index 0000000..f1f6abd --- /dev/null +++ b/App/tests/unit/education-levels.test.ts @@ -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') + }) +}) diff --git a/App/tests/unit/sim-05-trace.test.ts b/App/tests/unit/sim-05-trace.test.ts new file mode 100644 index 0000000..5691d5f --- /dev/null +++ b/App/tests/unit/sim-05-trace.test.ts @@ -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 +} + +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 { + const result: Record = {} + 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) + }) + } +}) diff --git a/App/tests/unit/sim-05-treibhaus.test.ts b/App/tests/unit/sim-05-treibhaus.test.ts new file mode 100644 index 0000000..72d3289 --- /dev/null +++ b/App/tests/unit/sim-05-treibhaus.test.ts @@ -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') + }) +}) diff --git a/App/tests/unit/sim-07-erdbeben.test.ts b/App/tests/unit/sim-07-erdbeben.test.ts new file mode 100644 index 0000000..2dad76c --- /dev/null +++ b/App/tests/unit/sim-07-erdbeben.test.ts @@ -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) + }) +}) diff --git a/App/tests/unit/sim-09-energiemix.test.ts b/App/tests/unit/sim-09-energiemix.test.ts new file mode 100644 index 0000000..d3b62fb --- /dev/null +++ b/App/tests/unit/sim-09-energiemix.test.ts @@ -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) + }) +}) diff --git a/App/tests/unit/sim-12-fluss.test.ts b/App/tests/unit/sim-12-fluss.test.ts new file mode 100644 index 0000000..5399e34 --- /dev/null +++ b/App/tests/unit/sim-12-fluss.test.ts @@ -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) + }) +}) diff --git a/App/tile-test.html b/App/tile-test.html new file mode 100644 index 0000000..f316b2f --- /dev/null +++ b/App/tile-test.html @@ -0,0 +1,60 @@ + + + + + + Kachel-Test + + + + + + + diff --git a/App/tsconfig.json b/App/tsconfig.json new file mode 100644 index 0000000..14f47f3 --- /dev/null +++ b/App/tsconfig.json @@ -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"] +} diff --git a/App/vite.config.ts b/App/vite.config.ts new file mode 100644 index 0000000..81194e2 --- /dev/null +++ b/App/vite.config.ts @@ -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', + }, +}) diff --git a/index.html b/index.html new file mode 100644 index 0000000..304a490 --- /dev/null +++ b/index.html @@ -0,0 +1,140 @@ + + + + + + GeoGraSim — Projektübersicht + + + + + +
    + +

    GeoGraSim

    +

    Interaktive Simulationen für den Geografieunterricht der Sekundarstufe I.
    Projektübersicht und alle Deliverables.

    + +

    🚀 App

    + + +

    📚 Forschung

    + + +

    📋 Konzept

    + + + + +
    + +