Files
geograsim/App/sims/klima/game-2d.html
T
Adminator 502f271f36 Einheitliche Sim-Kopfzeile (Entwurf B) als Komponente + Lizenz-Datenmodell
- ggs-header.css/.js: schwebende Pillen-Kopfzeile aus vorhandenem
  .ggs-header-Markup (Kreis-Logo/Titel/Level als eine Pille, eigene
  SVG-Icons, Tempo-Panel links + Zustandsbutton, Musik-Panel unten,
  30% transparente Untermenues, Glossar target=_blank, Lehrplan raus).
  Enhance-Ansatz: Spiel-Logik (IDs/Klassen, data-speed) bleibt unberuehrt.
- Eingebaut in Klima 2D/3D, Fluss, Logistik (Auto-Tempo), Energiemanager,
  Kofferdetektiv 3D.
- admin-licenses.html: Datenmodell-Sektion (licenses-Tabelle, Lebenszyklus
  Generiert->Eingeloest->Zugewiesen, Beziehungen, Jahr->Schuljahr->Code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 14:06:41 +02:00

4578 lines
188 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>GeoGraSim — Klimawächter 2D</title>
<link rel="icon" type="image/svg+xml" href="../../favicon.svg">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<link rel="stylesheet" href="../../assets/css/design-system.css">
<link rel="stylesheet" href="../../assets/css/ggs-header.css">
<script src="../../assets/js/ggs-header.js"></script>
<style>
/* ================== MODUL-SPEZIFISCHE STYLES ================== */
/* Sim-Zone: flex column, damit Canvas den Rest unterhalb der Topbar füllt */
.ggs-zone-sim {
display: flex;
flex-direction: column;
}
/* Zeitleiste direkt unter der Topbar: Skala von 2025 bis 2175,
10-Jahres-Haupt­striche mit Jahreszahl-Label, 5-Jahres-Nebenstriche,
aktueller Jetzt-Marker und klickbare Ereignis-Punkte. */
.kw-timeline {
position: relative;
height: 44px;
margin: 6px 20px 4px;
flex-shrink: 0;
user-select: none;
}
.kw-tl-bar {
position: absolute;
left: 0; right: 0; top: 18px;
height: 3px;
background: linear-gradient(to right, rgba(0,0,0,.15) 0%, rgba(0,0,0,.35) 50%, rgba(0,0,0,.15) 100%);
border-radius: 2px;
}
.kw-tl-ticks { position: absolute; inset: 0; pointer-events: none; }
.kw-tl-ticks > div {
position: absolute;
width: 1.5px;
background: rgba(30,40,50,0.6);
}
.kw-tl-ticks > div.minor { top: 14px; height: 6px; background: rgba(30,40,50,0.35); }
.kw-tl-ticks > div.major { top: 10px; height: 14px; background: rgba(30,40,50,0.75); width: 2px; }
.kw-tl-ticks > span {
position: absolute;
top: 26px;
font-size: 10px;
font-weight: 700;
color: var(--ggs-text);
transform: translateX(-50%);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kw-tl-ticks > span.decade { color: var(--ggs-fjord-dark); }
.kw-tl-dots { position: absolute; inset: 0; }
.kw-tl-dots > button {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.9);
transform: translateX(-50%);
cursor: pointer;
padding: 0;
box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform 0.15s var(--ggs-ease);
}
.kw-tl-dots > button:hover { transform: translateX(-50%) scale(1.45); z-index: 2; }
.kw-tl-dots > button.good { background: var(--ggs-moss); }
.kw-tl-dots > button.bad { background: var(--ggs-coral); }
.kw-tl-dots > button.neutral { background: var(--ggs-text-muted); }
.kw-tl-now {
position: absolute;
top: 8px; bottom: 16px;
width: 2.5px;
background: var(--ggs-fjord-dark);
transform: translateX(-50%);
box-shadow: 0 0 0 1px rgba(255,255,255,0.7), 0 0 6px rgba(74,124,138,.5);
pointer-events: none;
transition: left 0.4s var(--ggs-ease);
border-radius: 1px;
}
.kw-tl-now::before {
content: '';
position: absolute;
top: -5px; left: -4px;
width: 10px; height: 10px;
background: var(--ggs-fjord-dark);
border-radius: 50%;
border: 1.5px solid #fff;
box-shadow: 0 0 4px rgba(74,124,138,.6);
}
#sim-canvas {
display: block;
width: 100%;
flex: 1;
min-height: 0;
background: linear-gradient(to bottom, #9ac8dc 0%, #bfdceb 55%, #4a7c8a 100%);
}
/* Status-Farben der ggs-param (Varianten) */
.ggs-param-fill.critical { background: #b04a3a; }
/* Budget-Bilanz (unter Budget-Zeile) */
.kw-balance {
margin-top: 6px;
padding: 6px 8px;
background: var(--ggs-bg);
border-radius: var(--ggs-radius-sm);
display: grid;
grid-template-columns: 1fr auto;
gap: 3px 8px;
font-size: 11px;
color: var(--ggs-text-muted);
}
.kw-balance b { font-variant-numeric: tabular-nums; color: var(--ggs-text); font-weight: 700; }
.kw-balance .pos { color: var(--ggs-moss-dark); }
.kw-balance .neg { color: var(--ggs-coral); }
.kw-balance .net { font-weight: 800; color: var(--ggs-fjord-dark); border-top: 1px solid var(--ggs-border); padding-top: 3px; margin-top: 2px; }
/* Strom-Netz-Visualisierung (unter Strom-Zeile) */
.kw-power-grid {
margin-top: 6px;
padding: 6px 8px;
background: var(--ggs-bg);
border-radius: var(--ggs-radius-sm);
display: flex; flex-direction: column; gap: 4px;
}
.kw-pg-row { display: grid; grid-template-columns: 72px 1fr 48px; align-items: center; gap: 6px; }
.kw-pg-lbl { font-size: 10px; font-weight: 700; color: var(--ggs-text-muted); white-space: nowrap; text-align: left; }
.kw-pg-track { width: 100%; height: 8px; background: var(--ggs-bg-dark); border-radius: 4px; overflow: hidden; }
.kw-pg-fill { height: 100%; border-radius: 4px; transition: width .4s var(--ggs-ease), background-color .3s; }
.kw-pg-fill.cap { background: var(--ggs-moss); }
.kw-pg-fill.cap.low { background: var(--ggs-coral); }
.kw-pg-fill.dem { background: var(--ggs-fjord); }
.kw-pg-num { font-size: 10px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.kw-pg-status { font-size: 10px; font-weight: 700; text-align: center; padding-top: 2px; }
.kw-pg-status.ok { color: var(--ggs-moss-dark); }
.kw-pg-status.bad { color: var(--ggs-coral); animation: kw-pg-pulse 1.4s ease-in-out infinite; }
@keyframes kw-pg-pulse { 0%,100%{opacity:1} 50%{opacity:.55} }
/* Graph-Info-Button (i) + Unit-Chip */
.kw-graph-actions {
position: absolute;
top: 6px;
right: 10px;
display: flex;
gap: 4px;
z-index: 2;
}
/* Live-Wert-Badge als HTML (nicht im SVG, sonst wird es verzerrt) */
.kw-graph-live-val {
position: absolute;
top: 26px;
right: 10px;
color: #fff;
font-size: 11px;
font-weight: 800;
padding: 2px 8px;
border-radius: 4px;
font-variant-numeric: tabular-nums;
pointer-events: none;
z-index: 3;
}
.kw-graph-info-btn {
width: 16px; height: 16px; border-radius: 50%; border: none;
background: rgba(0,0,0,.1); color: var(--ggs-text); font-size: 10px; font-weight: 800;
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-family: inherit;
}
.kw-graph-info-btn:hover { background: var(--ggs-fjord); color: #fff; }
.kw-unit-chip {
display: inline-flex; align-items: center; gap: 3px;
background: rgba(0,0,0,.08); color: var(--ggs-text);
padding: 0 6px; border-radius: 4px;
font-size: 10px; font-weight: 700; cursor: help;
border: none; font-family: inherit;
}
.kw-unit-chip:hover { background: var(--ggs-fjord-light); color: var(--ggs-fjord-dark); }
/* Event-Stack-Item: Info-Button für Events mit infoKey */
.ggs-event-stack-item { cursor: pointer; }
.ggs-event-stack-item.has-info::after { content: ' ⓘ'; color: var(--ggs-fjord); font-weight: 900; }
/* Endscreen: Ziele-Liste mit ✓/✗ */
.kw-end-goals {
display: flex; flex-direction: column; gap: 4px;
text-align: left; margin: 12px 0;
}
.kw-end-goal {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
border-radius: var(--ggs-radius-sm);
background: var(--ggs-bg);
font-size: 13px;
}
.kw-end-goal.done { background: var(--ggs-moss-light); color: var(--ggs-moss-dark); }
.kw-end-goal.fail { background: var(--ggs-coral-light); color: var(--ggs-coral); }
.kw-end-goal-icon { font-weight: 900; width: 16px; text-align: center; }
/* Modal-Cards (Bürger-Events, Event-Detail, Info-Overlay): normale Modal-
Breite, opaker Hintergrund, sauberer Umbruch. */
.kw-info-card,
.kw-event-card,
.kw-citizen {
max-width: 560px;
text-align: left;
word-wrap: break-word;
overflow-wrap: anywhere;
position: relative; /* Anker für Badges wie Budget-Pill */
}
/* In-Canvas-Event-Notifications (oben mittig, erscheinen wenn Ereignis
frisch feuert): volle Canvas-Breite, sanft transparent — damit die
Meldung auffällt, aber die Szene dahinter durchscheint. */
/* Viewport spannt sich fast über volle Canvas-Breite (10px Rand links/rechts). */
.ggs-event-viewport {
left: 10px !important;
right: 10px !important;
transform: none !important;
align-items: stretch !important;
}
.ggs-event-notification {
max-width: none !important;
width: auto !important;
background: rgba(255, 255, 255, 0.78) !important;
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
text-align: center;
white-space: normal !important;
word-wrap: break-word;
overflow-wrap: anywhere;
padding: 8px 16px !important;
font-size: 15px !important;
line-height: 1.35 !important;
}
.kw-info-card h3 {
font-size: 22px; font-weight: 800; color: var(--ggs-fjord-dark);
margin-bottom: 14px;
}
.kw-info-card p {
font-size: 15px; color: var(--ggs-text);
line-height: 1.7; margin-bottom: 12px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.kw-info-hint {
background: var(--ggs-fjord-light);
border-left: 3px solid var(--ggs-fjord);
padding: 12px 16px;
border-radius: 0 var(--ggs-radius-sm) var(--ggs-radius-sm) 0;
font-size: 14px; color: var(--ggs-fjord-dark);
margin: 12px 0 18px;
line-height: 1.5;
}
/* Achievement-Badges: bestehende Badges NICHT bei jedem Re-Render pop-animieren.
Nur frisch freigeschaltete (ohne .kw-ach-still) laufen die Design-System-Animation. */
.ggs-badge-earned.kw-ach-still { animation: none !important; }
.ggs-badge-earned.kw-ach-locked {
background: var(--ggs-bg-dark) !important;
border-color: var(--ggs-border) !important;
color: var(--ggs-text-muted);
opacity: .45;
animation: none !important;
}
.ggs-badge-earned:not(.kw-ach-locked) { width: 36px; height: 36px; font-size: 18px; }
/* Event-Detail-Modal — Inhalts-Details (Rest der Card kommt aus dem
gemeinsamen Regelwerk oben). */
.kw-event-card .kw-ev-head {
display: flex; align-items: center; gap: 12px; margin-bottom: 12px;
}
.kw-event-card .kw-ev-icon {
font-size: 40px; width: 56px; height: 56px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--ggs-fjord-light);
}
.kw-event-card.good .kw-ev-icon { background: var(--ggs-moss-light); }
.kw-event-card.bad .kw-ev-icon { background: var(--ggs-coral-light); }
.kw-event-card .kw-ev-year {
font-size: 11px; font-weight: 700; letter-spacing: .5px;
text-transform: uppercase; color: var(--ggs-text-muted);
}
.kw-event-card .kw-ev-title {
font-size: 17px; font-weight: 800; color: var(--ggs-fjord-dark);
line-height: 1.3;
}
.kw-event-card .kw-ev-text {
font-size: 14px; line-height: 1.6; color: var(--ggs-text);
margin-bottom: 10px; white-space: pre-line;
word-wrap: break-word;
overflow-wrap: anywhere;
}
/* Maßnahmen: ausgegraut, wenn nicht leistbar, aber klickbar lassen ("zu teuer") */
.ggs-card.unaffordable {
opacity: .55;
}
.ggs-card.unaffordable .ggs-badge.cost { background: var(--ggs-coral-light); color: var(--ggs-coral); }
/* Bau-Karte aktiv ausgewählt — kräftiges Grün (analog zu 3D-Placement-Modus).
In 2D ist der Kauf zwar einmalig, der grüne Blitz für ~350 ms gibt aber das
gleiche visuelle Feedback wie in 3D, wo die Auswahl bis zur Platzierung
bleibt. */
.ggs-card.kw-card-selected {
border-color: var(--ggs-moss) !important;
background: var(--ggs-moss-light) !important;
box-shadow: 0 0 0 3px rgba(106, 155, 86, 0.35), var(--ggs-shadow-md) !important;
transform: translateY(-1px);
}
.ggs-card.kw-card-selected .ggs-card-title { color: var(--ggs-moss-dark); }
.ggs-card-count {
margin-left: auto;
font-size: 11px;
font-weight: 700;
color: var(--ggs-white);
background: var(--ggs-fjord);
padding: 1px 7px;
border-radius: var(--ggs-radius-pill);
}
.ggs-card-demolish {
margin-left: 6px;
font-size: 11px;
color: var(--ggs-coral);
background: transparent;
border: 1px solid var(--ggs-coral-light);
border-radius: var(--ggs-radius-sm);
padding: 1px 6px;
cursor: pointer;
}
.ggs-card-demolish:hover { background: var(--ggs-coral-light); }
/* Level-Select */
.kw-level-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.kw-level-card {
background: var(--ggs-bg);
border: 2px solid var(--ggs-border);
border-radius: var(--ggs-radius-md);
padding: 14px 10px;
cursor: pointer;
text-align: center;
transition: all var(--ggs-duration) var(--ggs-ease);
}
.kw-level-card:hover { border-color: var(--ggs-fjord); transform: translateY(-2px); box-shadow: var(--ggs-shadow-md); }
.kw-level-card .em { font-size: 34px; display: block; margin-bottom: 4px; }
.kw-level-card .lbl { font-size: 14px; font-weight: 800; color: var(--ggs-fjord-dark); }
.kw-level-card .sub { font-size: 10px; color: var(--ggs-text-muted); margin-top: 4px; line-height: 1.3; }
/* Bürger-Event (Choice-Dialog) — Rest der Card kommt aus dem gemeinsamen
Regelwerk oben (volle Breite, transparent, Umbruch, position:relative). */
/* Aktuelles Budget im Bürger-Dialog oben rechts — sichtbar bleiben, wenn
der Overlay die Status-Spalte überdeckt. */
.kw-citizen-budget {
position: absolute;
top: 14px;
right: 18px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--ggs-moss-light);
color: var(--ggs-moss-dark);
border: 1.5px solid var(--ggs-moss);
border-radius: var(--ggs-radius-pill);
padding: 4px 12px;
font-size: 13px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.kw-citizen-budget.low {
background: var(--ggs-coral-light);
color: var(--ggs-coral);
border-color: var(--ggs-coral);
}
.kw-citizen-budget-icon { font-size: 15px; }
.kw-citizen-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.kw-citizen-char { font-size: 38px; }
.kw-citizen-name { font-size: 15px; font-weight: 800; color: var(--ggs-fjord-dark); }
.kw-citizen-msg {
background: var(--ggs-bg);
border-left: 3px solid var(--ggs-fjord);
padding: 10px 14px;
border-radius: var(--ggs-radius-sm);
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
font-style: italic;
color: var(--ggs-text);
}
.kw-choice {
display: block;
width: 100%;
text-align: left;
background: var(--ggs-white);
border: 1.5px solid var(--ggs-border);
border-radius: var(--ggs-radius-sm);
padding: 10px 14px;
margin-bottom: 8px;
cursor: pointer;
transition: all var(--ggs-duration) var(--ggs-ease);
font-family: var(--ggs-font);
}
.kw-choice:hover { border-color: var(--ggs-fjord); background: var(--ggs-fjord-light); }
.kw-choice-title { font-size: 13px; font-weight: 700; color: var(--ggs-fjord-dark); }
.kw-choice-desc { font-size: 11px; color: var(--ggs-text-muted); margin-top: 2px; }
/* Loading spinner in die tägliche Tick-Animation */
.kw-tick-dot {
display: inline-block;
width: 6px; height: 6px; border-radius: 50%;
background: var(--ggs-fjord);
margin-left: 6px;
animation: kw-tick-pulse 1s infinite;
}
@keyframes kw-tick-pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: .4; transform: scale(1.4); }
}
.paused .kw-tick-dot { animation: none; background: var(--ggs-orange); }
/* Header-Module Farbe bei Pause */
.ggs-header.paused .ggs-header-module::after {
content: ' (pausiert)';
color: var(--ggs-orange);
font-size: 12px;
font-weight: 600;
}
/* ====================================================================
iPad / Touch-Polish (überarbeitet 2026-05-02 16:50):
Schmale Bau-Spalte als Default, Expand-Toggle (-Icon) ganz oben.
Expand-Modus zeigt die volle Desktop-Card-Variante; sobald gebaut/
getippt wird, klappt's wieder zurück.
KEIN Center-Modal mehr (Vor-Version war falsch interpretiert).
Querformat-Hinweis bei Portrait, weil das Spiel im Portrait nicht
funktioniert.
==================================================================== */
@media (pointer: coarse) {
.ggs-header-logo-text { display: none !important; }
.ggs-header-logo-icon { height: 32px !important; }
.ggs-header-separator { display: none !important; }
.ggs-header-module {
font-size: 13px !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.ggs-header-badge {
font-size: 10px !important;
white-space: nowrap;
}
.ggs-event-notification {
padding: 4px 10px !important;
font-size: 11.5px !important;
line-height: 1.25 !important;
}
.ggs-event-notification .ev-icon {
font-size: 14px !important;
margin-right: 4px !important;
}
.ggs-music-sel {
width: 38px !important;
padding-left: 4px !important;
padding-right: 0 !important;
}
/* Schmale Bau-Spalte: Grid-Spalte 3 wird auf 76 px gedrückt, ein
Toggle-Button oben klappt sie temporär zur Desktop-Variante aus.
Im expand-Modus liegt die Spalte als Overlay über dem Canvas, damit
Karte und Status nicht erneut umsortiert werden. */
.ggs-sim-layout {
grid-template-columns: 215px 1fr 76px !important;
position: relative; /* Ankerpunkt für das Expand-Overlay */
}
.ggs-zone-actions { padding: 4px !important; overflow-x: hidden; }
.ggs-zone-actions .ggs-panel { padding: 0 !important; }
.ggs-zone-actions .ggs-panel-title { display: none; }
#measures-container { padding: 0 !important; }
/* Toggle-Button () ganz oben */
.kw-build-toggle {
display: flex !important;
align-items: center; justify-content: center;
width: 60px; height: 44px; margin: 4px auto 8px;
border-radius: 12px;
border: 1.5px solid var(--ggs-border);
background: var(--ggs-bg-dark);
font-size: 18px;
cursor: pointer;
user-select: none;
}
.kw-build-toggle.is-open {
background: var(--ggs-fjord-light);
border-color: var(--ggs-fjord);
color: var(--ggs-fjord-dark);
}
/* Default: Karten kompakt (nur Emoji + count) */
.ggs-card {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
width: 60px !important;
height: 60px !important;
margin: 0 auto 8px !important;
padding: 4px !important;
position: relative;
border-radius: 12px !important;
}
.ggs-card .ggs-card-header { gap: 0 !important; margin: 0 !important; }
.ggs-card .ggs-card-icon { font-size: 28px !important; }
.ggs-card .ggs-card-title,
.ggs-card .ggs-card-desc,
.ggs-card .ggs-card-badges,
.ggs-card .ggs-card-demolish { display: none !important; }
.ggs-card .ggs-card-count {
position: absolute;
top: 2px; right: 2px;
margin: 0 !important;
font-size: 10px;
padding: 1px 5px;
}
.ggs-card.kw-card-owned {
border-color: var(--ggs-moss) !important;
box-shadow: 0 0 0 2px rgba(106, 155, 86, 0.30);
}
/* Expand-Modus: Spalte als Overlay über dem Canvas, volle Desktop-Card */
.ggs-zone-actions.kw-build-expanded {
position: absolute !important;
top: 0; right: 0; bottom: 0;
width: 280px !important;
background: var(--ggs-bg);
z-index: 50;
box-shadow: -6px 0 18px rgba(0,0,0,0.18);
padding: 8px !important;
overflow-y: auto;
animation: kw-build-slidein 0.18s ease-out;
}
.ggs-zone-actions.kw-build-expanded .ggs-panel-title { display: block; }
.ggs-zone-actions.kw-build-expanded #measures-container { padding: 0 6px 16px !important; }
.ggs-zone-actions.kw-build-expanded .kw-build-toggle { width: 100%; }
/* Im Expand-Modus rendern wir die Karten wie Desktop */
.ggs-zone-actions.kw-build-expanded .ggs-card {
display: block !important;
width: auto !important;
height: auto !important;
margin: 0 0 6px !important;
padding: 10px 12px !important;
border-radius: var(--ggs-radius-md) !important;
}
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-header { gap: var(--ggs-gap-sm) !important; }
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-icon { font-size: 20px !important; }
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-title,
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-desc,
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-badges,
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-demolish { display: revert !important; }
.ggs-zone-actions.kw-build-expanded .ggs-card .ggs-card-count { position: static; }
/* Graph-Zoom-Overlay: design-system kappt auf max-height: 400 px, was am
iPad-Landscape (~820 px hoch) den Inhalt unten abschneidet. Lokal
größer machen, damit SVG + Header sicher reinpassen. */
.ggs-graph-zoom-card {
height: 78dvh !important;
max-height: 92dvh !important;
width: 92vw !important;
max-width: 92vw !important;
padding-bottom: max(var(--ggs-gap-lg), env(safe-area-inset-bottom)) !important;
}
}
/* Achievement-Tooltip: title-Attribute öffnen sich auf Touch nicht; daher
ein eigenes Tap-Popup mit Titel + Beschreibung. */
.kw-ach-tooltip {
position: fixed;
z-index: 1500;
max-width: 240px;
background: var(--ggs-fjord-dark);
color: var(--ggs-white);
padding: 8px 12px;
border-radius: var(--ggs-radius-sm);
font-size: 13px;
line-height: 1.4;
box-shadow: var(--ggs-shadow-md);
pointer-events: none;
animation: ggs-fade-in 0.15s var(--ggs-ease) both;
}
@keyframes kw-build-slidein {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Querformat erzwingen: bei Portrait + Touch ein blockierender Hinweis */
.kw-rotate-hint {
display: none;
position: fixed; inset: 0;
background: var(--ggs-bg);
z-index: 99999;
align-items: center; justify-content: center;
flex-direction: column;
text-align: center;
padding: 24px;
}
.kw-rotate-hint .kw-rh-icon {
font-size: 72px; margin-bottom: 16px;
animation: kw-rotate-pulse 1.6s ease-in-out infinite;
}
.kw-rotate-hint h2 { font-size: 22px; margin-bottom: 8px; color: var(--ggs-fjord-dark); }
.kw-rotate-hint p { font-size: 15px; color: var(--ggs-text-muted); max-width: 320px; }
@media (orientation: portrait) and (pointer: coarse) {
.kw-rotate-hint { display: flex; }
}
@keyframes kw-rotate-pulse {
0%, 100% { transform: rotate(-90deg); }
50% { transform: rotate(0deg); }
}
/* iPad-Modal-Pattern (Logistik-Konvention 15:05): klima-lokale Sicherung
für lange Overlays — dvh statt vh, safe-area-inset-bottom, scrollbar. */
.ggs-overlay-card {
max-height: 92dvh;
overflow-y: auto;
padding-bottom: max(var(--ggs-gap-xl), env(safe-area-inset-bottom));
}
</style>
<script src="engine.js"></script>
<script src="audio.js"></script>
</head>
<body>
<!-- Querformat-Hinweis (zeigt sich nur bei iPad/Touch in Portrait) -->
<div class="kw-rotate-hint" aria-live="polite">
<div class="kw-rh-icon">📱</div>
<h2>Bitte Gerät drehen</h2>
<p>Klimawächter funktioniert im Querformat. Dreh dein Tablet horizontal — dann geht's weiter.</p>
</div>
<!-- ============================================================
LOADING SCREEN
============================================================ -->
<div class="ggs-loading" id="loading-screen">
<img src="../../assets/img/bildLogo.png" alt="" class="ggs-loading-logo">
<div class="ggs-loading-text">Klimawächter wird geladen …</div>
</div>
<!-- ============================================================
HEADER
============================================================ -->
<header class="ggs-header" id="header" data-ggs-sim="klima">
<a href="/schueler" class="ggs-header-logo">
<img src="../../assets/img/bildLogo.png" alt="" class="ggs-header-logo-icon">
<img src="../../assets/img/textlogo_geograsim.svg" alt="GeoGraSim" class="ggs-header-logo-text">
</a>
<div class="ggs-header-separator"></div>
<span class="ggs-header-module" data-ggs-name>Klimawächter 2D</span>
<span class="ggs-header-badge" id="level-badge">Level 1 · Lernen</span>
<span class="kw-tick-dot" id="tick-dot" title="Simulation läuft"></span>
<div class="ggs-header-spacer"></div>
<div class="ggs-header-actions">
<div class="ggs-speed" role="group" aria-label="Geschwindigkeit">
<button class="ggs-speed-btn" data-speed="0" title="Pause (P)"></button>
<button class="ggs-speed-btn active" data-speed="1" title="Normal (1) — empfohlen, ≈ 20 min">1×</button>
<button class="ggs-speed-btn" data-speed="2" title="Schnell (2) — ≈ 10 min">2×</button>
<button class="ggs-speed-btn" data-speed="4" title="Turbo (3) — ≈ 5 min">4×</button>
</div>
<!-- Music Player (Standard-Widget aus Design-System, via ggsMusicSetup befüllt) -->
<div class="ggs-music" id="ggs-music" hidden>
<select class="ggs-music-sel" id="ggs-music-sel" title="Hintergrundmusik wählen"></select>
<button class="ggs-music-btn" id="ggs-music-toggle" title="Play / Pause"></button>
<input class="ggs-music-vol" id="ggs-music-vol" type="range" min="0" max="100" value="35" title="Lautstärke">
</div>
<button class="ggs-btn-ghost" id="btn-info" title="Anleitung nochmal zeigen" onclick="if(window.GGS_TUTORIAL&&typeof window.GGS_TUTORIAL.replay==='function')window.GGS_TUTORIAL.replay();"></button>
<a class="ggs-btn-ghost" id="ggs-lehrplan-link" href="#" target="_blank" rel="noopener" title="Lehrplan-Bezug">📚</a>
<button class="ggs-btn-ghost" id="btn-sound" title="SFX an/aus">🔊</button>
<button class="ggs-btn-ghost" id="btn-reset" title="Neu starten (Spielstand löschen)">🔄</button>
<a href="/schueler" class="ggs-btn-ghost" title="Zurück zum Cockpit">🏠</a>
</div>
</header>
<!-- ============================================================
MAIN LAYOUT
============================================================ -->
<main class="ggs-sim-layout">
<!-- LINKS: Status ------------------------------------------------ -->
<aside class="ggs-zone-status">
<!-- Status: nur 3 Ressourcen (CO₂/Temp/Meer nur in Graphen sichtbar) -->
<div class="ggs-panel">
<div class="ggs-panel-title">Status</div>
<!-- Budget mit Jahres-Bilanz -->
<div class="ggs-param">
<span class="ggs-param-icon">💰</span>
<span class="ggs-param-label" data-glossar="budget">Budget</span>
<span class="ggs-param-value" data-stat="budget"></span>
</div>
<div class="kw-balance" id="budget-balance">
<span data-glossar="steuern">+ Steuern</span><b class="pos" data-stat="b-income">0</b>
<span class="b-tourism-row" style="display:none" data-glossar="tourismus">+ Tourismus</span><b class="pos b-tourism-num" style="display:none" data-stat="b-tourism">0</b>
<span data-glossar="wartung"> Wartung</span><b class="neg" data-stat="b-upkeep">0</b>
<span class="b-climate-row" style="display:none" data-glossar="klimafolgen"> Klima-Schaden</span><b class="neg b-climate-num" style="display:none" data-stat="b-climate">0</b>
<span class="net">= pro Jahr</span><b data-stat="b-net">0</b>
</div>
<!-- Bevölkerung -->
<div class="ggs-param" style="margin-top:10px">
<span class="ggs-param-icon">👥</span>
<span class="ggs-param-label" data-glossar="bevoelkerung">Bevölkerung</span>
<span class="ggs-param-value" data-stat="population"></span>
</div>
<!-- Strom mit Netz-Visualisierung -->
<div class="ggs-param" style="margin-top:10px">
<span class="ggs-param-icon"></span>
<span class="ggs-param-label" data-glossar="strom">Strom</span>
<span class="ggs-param-value" data-stat="power"></span>
</div>
<div class="kw-power-grid">
<div class="kw-pg-row">
<span class="kw-pg-lbl">⚡ Kapazität</span>
<div class="kw-pg-track"><div class="kw-pg-fill cap" data-pg="cap" style="width:0%"></div></div>
<span class="kw-pg-num" data-pg="cap-num">0</span>
</div>
<div class="kw-pg-row">
<span class="kw-pg-lbl">🏠 Bedarf</span>
<div class="kw-pg-track"><div class="kw-pg-fill dem" data-pg="dem" style="width:0%"></div></div>
<span class="kw-pg-num" data-pg="dem-num">0</span>
</div>
<div class="kw-pg-status ok" data-pg="status" data-glossar="blackout">Versorgung gesichert</div>
</div>
</div>
<!-- Ziele -->
<div class="ggs-panel">
<div class="ggs-panel-title">Ziele</div>
<div id="goals-container"></div>
</div>
<!-- Achievements -->
<div class="ggs-panel">
<div class="ggs-panel-title">Erfolge</div>
<div id="achievements-container" style="display:flex;gap:6px;flex-wrap:wrap;"></div>
</div>
</aside>
<!-- MITTE: Simulation ------------------------------------------- -->
<section class="ggs-zone-sim">
<div class="ggs-topbar">
<div class="ggs-topbar-stat">
<span class="icon">📅</span>
<span class="val" data-stat="year">Jahr 2025</span>
</div>
<div class="ggs-topbar-stat">
<span class="icon">🎯</span>
<span class="val" data-stat="tick">Runde 0 / 75</span>
</div>
<div class="ggs-topbar-stat">
<span class="icon">☁️</span>
<span class="val" data-stat="weather">Mild</span>
</div>
<div class="ggs-topbar-stat">
<span class="icon"></span>
<span class="val" data-stat="rate">7,5 J/min</span>
</div>
<div class="ggs-header-spacer"></div>
<div class="ggs-topbar-stat">
<span class="icon">💰</span>
<span class="val" data-stat="income-line">+0 Mio/Jahr</span>
</div>
</div>
<!-- Zeitleiste mit 10-Jahres-Markierungen, aktueller Position und Ereignis-
Punkten (Grün=positiv, Rot=negativ, Grau=neutral). Klick auf einen
Punkt öffnet die Ereignis-Card. -->
<div class="kw-timeline" id="kw-timeline">
<div class="kw-tl-bar"></div>
<div class="kw-tl-ticks" id="kw-tl-ticks"></div>
<div class="kw-tl-dots" id="kw-tl-dots"></div>
<div class="kw-tl-now" id="kw-tl-now"></div>
</div>
<canvas id="sim-canvas"></canvas>
<div class="ggs-event-viewport" id="event-viewport"></div>
<div class="ggs-event-stack" id="event-stack">
<div class="ggs-event-stack-list" id="event-stack-list"></div>
<button class="ggs-event-stack-toggle" onclick="toggleEventStack()">
📋 Ereignisse <span class="ggs-event-stack-count" id="event-count">0</span>
</button>
</div>
</section>
<!-- RECHTS: Maßnahmen ------------------------------------------- -->
<aside class="ggs-zone-actions" id="zone-actions">
<div class="ggs-panel">
<button class="kw-build-toggle" id="kw-build-toggle"
title="Details ein-/ausklappen" style="display:none"></button>
<div class="ggs-panel-title">Maßnahmen kaufen</div>
<div id="measures-container"></div>
</div>
</aside>
<!-- UNTEN: Graphen (dynamisch gerendert, mit Zonen, Ref-Linien, Info) -->
<section class="ggs-zone-graphs">
<div class="ggs-graph-card" data-graph-key="co2">
<div class="ggs-graph-label" data-glossar="co2">🌫 CO₂</div>
<div class="kw-graph-actions">
<button class="kw-unit-chip" data-info="ppm" title="Was ist ppm?">ppm ⓘ</button>
<button class="kw-graph-info-btn" data-info="co2" title="Mehr über CO₂">i</button>
</div>
<svg class="ggs-graph-svg" data-graph-svg="co2" viewBox="0 0 200 100" preserveAspectRatio="none"></svg>
<div class="ggs-graph-axis" data-graph-axis="co2"></div>
</div>
<div class="ggs-graph-card" data-graph-key="temperature">
<div class="ggs-graph-label" data-glossar="temperatur">🌡 Temperatur</div>
<div class="kw-graph-actions">
<button class="kw-unit-chip" data-info="celsius" title="Was ist °C?">°C ⓘ</button>
<button class="kw-unit-chip" data-info="paris_goal" title="Pariser Klimaziel">Paris ⓘ</button>
<button class="kw-graph-info-btn" data-info="temperature" title="Mehr über Temperatur">i</button>
</div>
<svg class="ggs-graph-svg" data-graph-svg="temperature" viewBox="0 0 200 100" preserveAspectRatio="none"></svg>
<div class="ggs-graph-axis" data-graph-axis="temperature"></div>
</div>
<div class="ggs-graph-card" data-graph-key="budget">
<div class="ggs-graph-label" data-glossar="budget">💰 Budget</div>
<div class="kw-graph-actions">
<button class="kw-unit-chip" data-info="mio_euro" title="Was ist Mio €?">Mio € ⓘ</button>
<button class="kw-graph-info-btn" data-info="budget" title="Mehr über Budget">i</button>
</div>
<svg class="ggs-graph-svg" data-graph-svg="budget" viewBox="0 0 200 100" preserveAspectRatio="none"></svg>
<div class="ggs-graph-axis" data-graph-axis="budget"></div>
</div>
<div class="ggs-graph-card" data-graph-key="sealevel">
<div class="ggs-graph-label" data-glossar="meeresspiegel">🌊 Meer</div>
<div class="kw-graph-actions">
<button class="kw-unit-chip" data-info="cm" title="cm Meeresspiegel">cm ⓘ</button>
<button class="kw-graph-info-btn" data-info="sealevel" title="Mehr zum Meeresspiegel">i</button>
</div>
<svg class="ggs-graph-svg" data-graph-svg="sealevel" viewBox="0 0 200 100" preserveAspectRatio="none"></svg>
<div class="ggs-graph-axis" data-graph-axis="sealevel"></div>
</div>
</section>
</main>
<!-- ============================================================
INFO OVERLAY (kinderfreundliche Erklärungen)
============================================================ -->
<div class="ggs-overlay" id="info-overlay" style="display:none" onclick="if(event.target.id==='info-overlay')hideOverlay('info-overlay')">
<div class="ggs-overlay-card kw-info-card" id="info-card"></div>
</div>
<!-- ============================================================
TOASTS
============================================================ -->
<div class="ggs-toast-container" id="toast-container"></div>
<!-- ============================================================
LEVEL-SELECT OVERLAY (erster Start ohne URL-Parameter)
============================================================ -->
<div class="ggs-overlay" id="level-select-overlay" style="display:none">
<div class="ggs-overlay-card" style="max-width:560px">
<div style="font-size:42px" data-ggs-icon>🌍</div>
<h2 style="font-size:22px;font-weight:900;color:var(--ggs-fjord-dark);margin:8px 0 4px" data-ggs-name>Klimawächter 2D</h2>
<p style="color:var(--ggs-text-muted);font-size:13px;margin-bottom:6px">
Du bist <b>Klimaminister*in</b> einer kleinen Inselstadt. Bis zum Jahr 2175 musst du die Stadt durch die Klimakrise steuern. Wähle deinen Schwierigkeitsgrad.
</p>
<div class="kw-level-grid">
<div class="kw-level-card" onclick="startGame(1)">
<span class="em">🟢</span>
<div class="lbl">Lernen</div>
<div class="sub">Viel Budget · milde Folgen · 5 Kern-Maßnahmen</div>
<span class="ggs-badge level-easy" style="margin-top:6px;display:inline-block">Level 1</span>
</div>
<div class="kw-level-card" onclick="startGame(2)">
<span class="em">🟡</span>
<div class="lbl">Üben</div>
<div class="sub">Standard · alle Bürger-Events · 10 Maßnahmen</div>
<span class="ggs-badge level-medium" style="margin-top:6px;display:inline-block">Level 2</span>
</div>
<div class="kw-level-card" onclick="startGame(3)">
<span class="em">🔴</span>
<div class="lbl">Profi</div>
<div class="sub">Hart · schnelle Folgen · alle Maßnahmen</div>
<span class="ggs-badge level-hard" style="margin-top:6px;display:inline-block">Level 3</span>
</div>
</div>
</div>
</div>
<!-- ============================================================
TUTORIAL OVERLAY
============================================================ -->
<div class="ggs-overlay" id="tutorial-overlay" style="display:none">
<div class="ggs-overlay-card ggs-tutorial">
<div class="ggs-tutorial-dots" id="tutorial-dots"></div>
<div class="ggs-tutorial-title" id="tutorial-title">Willkommen!</div>
<div class="ggs-tutorial-text" id="tutorial-text"></div>
<div style="display:flex;gap:8px;justify-content:center">
<button class="ggs-btn ggs-btn-secondary" id="tutorial-skip" onclick="skipTutorial()">Tutorial überspringen</button>
<button class="ggs-btn ggs-btn-primary" id="tutorial-next" onclick="nextTutorialStep()">Weiter →</button>
</div>
</div>
</div>
<!-- ============================================================
CITIZEN EVENT DIALOG
============================================================ -->
<div class="ggs-overlay" id="citizen-overlay" style="display:none">
<div class="ggs-overlay-card kw-citizen">
<!-- Budget-Anzeige oben rechts: während der Bürger-Dialog aktiv ist,
sieht man sonst das linke Status-Panel nicht. Ermöglicht bewusste
Entscheidungen. -->
<div class="kw-citizen-budget" id="citizen-budget">
<span class="kw-citizen-budget-icon">💰</span>
<span class="kw-citizen-budget-val"> Mio €</span>
</div>
<div class="kw-citizen-head">
<div class="kw-citizen-char" id="citizen-char">👤</div>
<div>
<div class="kw-citizen-name" id="citizen-name">Name</div>
<div style="font-size:11px;color:var(--ggs-text-muted)" id="citizen-sub">Bürger*in aus der Stadt</div>
</div>
</div>
<div class="kw-citizen-msg" id="citizen-msg">Nachricht</div>
<div id="citizen-choices"></div>
</div>
</div>
<!-- ============================================================
BETWEEN-LEVEL SCREEN (nach Level-Abschluss)
============================================================ -->
<div class="ggs-overlay" id="level-overlay" style="display:none">
<div class="ggs-overlay-card ggs-level-screen">
<div style="font-size:48px" id="level-emoji">🎉</div>
<h2 id="level-title" style="font-size:20px;font-weight:800;color:var(--ggs-fjord-dark);margin:12px 0 4px">Level geschafft!</h2>
<div class="ggs-stars" id="level-stars" style="margin:12px 0;">
<span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span>
</div>
<div class="ggs-badges-earned" id="level-badges"></div>
<div class="ggs-reflection">
<div class="ggs-reflection-q" id="reflection-question">Was war deine wirksamste Entscheidung?</div>
<div id="reflection-options"></div>
</div>
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px">
<button class="ggs-btn ggs-btn-secondary" onclick="location.href='../../'">Zurück zur Startseite</button>
<button class="ggs-btn ggs-btn-primary" id="level-next-btn" onclick="goNextLevel()">Nächstes Level →</button>
</div>
</div>
</div>
<!-- ============================================================
ENDSCREEN
============================================================ -->
<div class="ggs-overlay" id="end-overlay" style="display:none">
<div class="ggs-overlay-card ggs-results">
<div class="ggs-results-icon" id="end-icon">🏆</div>
<div class="ggs-results-title" id="end-title">Simulation abgeschlossen!</div>
<div class="ggs-results-subtitle" id="end-subtitle">Du hast deine Stadt durch 150 Jahre geführt.</div>
<div class="ggs-stars" id="end-stars" style="margin-bottom:20px">
<span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span><span class="ggs-star"></span>
</div>
<div class="ggs-results-stats" id="end-stats"></div>
<div style="display:flex;gap:12px;justify-content:center">
<button class="ggs-btn ggs-btn-primary" onclick="for(var i=1;i<=3;i++)localStorage.removeItem('ggs-save-klima-'+i);location.reload()">Nochmal spielen</button>
<button class="ggs-btn ggs-btn-secondary" onclick="location.href='../../'">Zurück</button>
</div>
</div>
</div>
<!-- ============================================================
SCRIPT
============================================================ -->
<script>
"use strict";
// Fallback-Kontext (direkter HTML-Zugriff ohne PHP).
// Für direkten File-Zugriff leiten wir basePath/apiUrl aus der URL ab,
// damit Glossar-API und Links trotzdem funktionieren.
(function() {
if (window.__GGS__) return;
// URL: .../App/sims/klima/game-2d.html → basePath = .../App
const path = location.pathname.replace(/\/sims\/klima\/game-2d\.html$/, '');
window.__GGS__ = {
sessionId: '', studentName: 'Gast', classId: null,
simId: 'klima', simName: 'Klimawächter 2D', level: 1,
levelConfig: null,
baseUrl: location.origin + path,
basePath: path,
apiUrl: path + '/php/api',
};
})();
/* ============================================================
GAME DATA — übernommen aus V1 (App/src/sims/sim-05-treibhaus)
============================================================ */
// Single Source of Truth: MEASURES + DIFFICULTY + CO2_FLOOR + TEMP_FLOOR
// leben in engine.js (window.KlimaEngine). Hier nur Aliases, damit alle
// bestehenden Verweise unverändert weiterlaufen.
const MEASURES = KlimaEngine.MEASURES;
const DIFFICULTY = KlimaEngine.DIFFICULTY;
const CO2_FLOOR = KlimaEngine.CO2_FLOOR;
const TEMP_FLOOR = KlimaEngine.TEMP_FLOOR;
/* ============================================================
ACHIEVEMENTS
============================================================ */
const ACHIEVEMENTS = KlimaEngine.ACHIEVEMENTS;
/* ============================================================
GLOSSAR
============================================================ */
const GLOSSAR = {
budget: { title:'Budget', text:'Das verfügbare Geld für Maßnahmen. Du erhältst jedes Jahr Steuereinnahmen von der Bevölkerung und musst davon die Wartung deiner Maßnahmen bezahlen.', unit:'in Mio € (Millionen Euro)' },
bevoelkerung: { title:'Bevölkerung', text:'Die Menschen deiner Inselstadt. Sie zahlen Steuern (Einnahmen) und brauchen Strom. Wenn die Insel unbewohnbar wird (Überflutung, Stromausfall), wandern sie ab.', unit:'in Personen' },
strom: { title:'Stromversorgung', text:'Dein Strombedarf wächst mit der Bevölkerung: ca. 1 MW pro 1.000 Einwohner. Fehlt Strom, wird es kalt — die Leute fällen Bäume und verbrennen Holz, was CO₂ freisetzt.', unit:'Leistung in MW (Megawatt)' },
co2: { title:'CO₂ (Kohlendioxid)', text:'Ein farbloses Gas, das beim Verbrennen von Kohle, Öl und Gas entsteht. Es verstärkt den Treibhauseffekt — der wichtigste Treiber des Klimawandels.', unit:'in ppm (parts per million)', link:'https://de.wikipedia.org/wiki/Kohlenstoffdioxid' },
ppm: { title:'ppm (parts per million)', text:'Anteilseinheit: 1 ppm bedeutet ein Teilchen pro Million. Bei CO₂ misst man, wie viele CO₂-Moleküle pro Million Luftmoleküle in der Atmosphäre sind. Vorindustriell ~280 ppm, heute ~425 ppm.', unit:'1 ppm = 1 Teilchen / 1.000.000', link:'https://de.wikipedia.org/wiki/Parts_per_million' },
megatonne: { title:'Megatonne (Mt)', text:'Eine Million Tonnen. Österreich stößt etwa 70 Mt CO₂ pro Jahr aus.', unit:'1 Mt = 1.000.000 Tonnen' },
megawatt: { title:'Megawatt (MW)', text:'Einheit der elektrischen Leistung. 1 MW = 1 Million Watt. Reicht für ca. 1.000 Haushalte, je nach Verbrauch. Ein modernes Windrad liefert 3 5 MW, eine große Solaranlage 1 MW oder mehr.', unit:'1 MW = 1.000.000 Watt', link:'https://de.wikipedia.org/wiki/Watt_(Einheit)' },
temperatur: { title:'Globale Durchschnittstemperatur', text:'Seit der Industrialisierung (~1850) ist sie um etwa 1,2 °C gestiegen. Das Pariser Abkommen will den Anstieg auf 1,5 °C begrenzen.', unit:'°C', link:'https://de.wikipedia.org/wiki/Globale_Erwärmung' },
meeresspiegel:{ title:'Meeresspiegel', text:'Steigt durch schmelzendes Eis und die Ausdehnung warmen Wassers. Seit 1900 rund 20 cm. Bis 2100 möglicherweise über 1 m.', unit:'in cm über Startniveau', link:'https://de.wikipedia.org/wiki/Meeresspiegelanstieg' },
ueberflutung: { title:'Überflutete Fläche', text:'Prozentanteil der Siedlungen, die unter Wasser stehen, sobald der Meeresspiegel den Schutz (Deiche etc.) überschreitet.', unit:'in %' },
windpark: { title:'Windpark', text:'Gruppe von Windkraftanlagen. Rotoren wandeln Wind in elektrische Energie um. Oft an Küsten oder auf See (Offshore).', unit:'Leistung in MW', link:'https://de.wikipedia.org/wiki/Windpark' },
solaranlage: { title:'Solaranlage', text:'Wandelt Sonnenlicht in Strom um (Photovoltaik). Dachanlage: 5 10 kW, Solarpark: mehrere MW.', unit:'Leistung in kW/MW', link:'https://de.wikipedia.org/wiki/Photovoltaikanlage' },
deich: { title:'Deich', text:'Künstlicher Damm aus Erde oder Stein, der Siedlungen vor Hochwasser schützt. Wirkt sofort, braucht aber Pflege.', link:'https://de.wikipedia.org/wiki/Deich' },
mangrove: { title:'Mangroven', text:'Salzwasser-verträgliche Bäume an tropischen Küsten. Binden CO₂ und schützen vor Erosion — Naturlösung gegen zwei Probleme.', link:'https://de.wikipedia.org/wiki/Mangroven' },
co2_filter: { title:'CO₂-Filter (Carbon Capture)', text:'Technologie, die CO₂ aus der Luft filtert. Noch teuer und energieintensiv, wird als Ergänzung erforscht.', link:'https://de.wikipedia.org/wiki/CO2-Abscheidung_und_-Speicherung' },
kohlekraftwerk:{ title:'Kohlekraftwerk', text:'Verbrennt Kohle, erzeugt Hitze, treibt Dampfturbinen an. Liefert viel Strom — aber extrem hoher CO₂-Ausstoß pro Kilowattstunde.', link:'https://de.wikipedia.org/wiki/Kohlekraftwerk' },
treibhauseffekt:{ title:'Treibhauseffekt', text:'CO₂ und andere Gase lassen Sonnenstrahlung durch, halten aber Wärmestrahlung zurück. Das macht die Erde bewohnbar — zu viel davon heizt sie jedoch auf.', link:'https://de.wikipedia.org/wiki/Treibhauseffekt' },
steuern: { title:'Steuern', text:'Jährliche Einnahmen aus der Bevölkerung. Hängen von der Einwohnerzahl und der Wirtschaftslage ab.', unit:'in Mio €/Jahr' },
wartung: { title:'Wartungskosten', text:'Jede Maßnahme kostet auch nach dem Bau Geld — Reparaturen, Personal, Austausch. Vorsicht: Viele Maßnahmen können dein Budget langfristig auffressen.', unit:'in Mio €/Jahr' },
klimafolgen: { title:'Klima-Folgekosten', text:'Salzwasser versalzt Böden und Brunnen, Entsalzungsanlagen kosten Geld. Tritt auf, sobald der Meeresspiegel ca. 25 cm übersteigt.', unit:'in Mio €/Jahr' },
erneuerbar: { title:'Erneuerbare Energie', text:'Wind, Sonne, Wasser, Geothermie — Quellen, die nicht verbraucht werden und kein CO₂ ausstoßen.', link:'https://de.wikipedia.org/wiki/Erneuerbare_Energie' },
tourismus: { title:'Tourismus', text:'Bringt Einnahmen — aber Flüge und Hotels verursachen CO₂. Kollabiert, sobald der Strand vom Meer verschluckt wird.', link:'https://de.wikipedia.org/wiki/Massentourismus' },
blackout: { title:'Stromausfall', text:'Wenn dein Kraftwerkspark weniger Strom liefert als gebraucht wird. Menschen fällen Bäume zum Heizen → weniger CO₂-Senken, mehr Ausstoß.' },
kuestenschutz:{ title:'Küstenschutz', text:'Maßnahmen gegen Überflutung und Erosion: Deiche, Wellenbrecher, Mangroven, Hochwasserschutzmauern.', link:'https://de.wikipedia.org/wiki/Küstenschutz' },
};
/* ============================================================
TUTORIAL STEPS
============================================================ */
// Tutorial-Texte dürfen HTML enthalten — Fachbegriffe als
// <span data-glossar="key"> sind klickbar (Popup) und haben im Popup
// einen Link zum vollen Glossar-Eintrag (öffnet in neuem Tab).
const TUTORIAL_STEPS = [
{ title:'Willkommen, Klimaminister*in!',
html:'Es ist das Jahr 2025. Du übernimmst die Verantwortung für eine kleine Inselstadt. Bis zum Jahr 2175 — also 150 Jahre — liegt ihr Schicksal in deiner Hand.<br><br>'
+'Die Wissenschaft warnt: Das Klima verändert sich. Wenn du nichts tust, wird die Temperatur steigen, das Eis schmelzen und der Meeresspiegel deine Stadt überfluten.' },
{ title:'Das Klima-System',
html:'Links siehst du die wichtigsten Werte:<br><br>'
+'🌫️ <span data-glossar="co2">CO₂</span> (Kohlendioxid — ein Treibhausgas) steigt derzeit rund 2,5 <span data-glossar="ppm">ppm</span> (parts per million, also CO₂-Teilchen pro Million Luftteilchen) pro Jahr.<br><br>'
+'🌡️ Die <span data-glossar="temperatur">Temperatur</span> reagiert träge auf CO₂ — rund +3 °C pro Verdopplung.<br><br>'
+'🌊 Wärmer = mehr Eis schmilzt = höherer <span data-glossar="meeresspiegel">Meeresspiegel</span>.<br><br>'
+'Unten siehst du die Zeitverläufe — klicke drauf, um sie zu vergrössern. '
+'Unterstrichene Fachbegriffe kannst du anklicken: du bekommst eine Kurzerklärung und einen Link zum vollen Glossar-Eintrag in einem neuen Tab.' },
{ title:'Massnahmen kaufen',
html:'Rechts findest du Massnahmen: <b>Wald</b> günstig aber langsam · <b>Solar</b> mittlerer Preis · <b>Windpark</b> teuer, aber viel sauberer Strom · <b>Deich</b> schützt Küsten.<br><br>'
+'Jede Massnahme hat Anschaffungskosten UND jährliche <span data-glossar="wartung">Wartung</span>. Plane sparsam!' },
{ title:'Strom für die Insel',
html:'Pro 1.000 Einwohner braucht deine Insel ca. 1 <span data-glossar="megawatt">MW</span> (Megawatt — 1 Mio. Watt; 1 MW deckt ca. 1.000 Haushalte) Strom.<br><br>'
+'Fehlt Strom, wird es kalt — Leute fällen Bäume, verbrennen Holz, das setzt CO₂ frei. Kohle liefert viel Strom billig, stösst aber riesige Mengen CO₂ aus. Tückisch!<br><br>'
+'Ein <span data-glossar="windpark">Windpark</span> liefert ca. 3 MW, eine <span data-glossar="solaranlage">Solaranlage</span> 1 MW.' },
{ title:'Los geht\'s!',
html:'Du kannst jederzeit pausieren ⏸ oder schneller spielen ⏩.<br><br>'
+'Deine Aufgabe: bis 2175 überleben, Temperatur unter +2 °C halten, nicht pleite gehen, die Stadt schützen.<br><br>'
+'Viel Erfolg! 🌍' }
];
/* ============================================================
INFO-TOPICS (kinderfreundliche Erklärungen, wie in 3D-Version)
============================================================ */
const INFO_TOPICS = {
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.',
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. 4 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 sie schneller als neuer Schnee nachkommt. Am Ende sind sie ganz verschwunden — und das Trinkwasser im Sommer fehlt.',
hint: 'Der Vulkan auf deiner Insel hat eine Gletscherkappe. Bei steigender Temperatur schrumpft sie sichtbar.',
},
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 deiner Insel: Die Vorderseite wird zuerst nass, weil sie fast auf Meereshöhe liegt.\n\n' +
'Das soll zeigen: Man kann mit Deichen Häuser schützen — aber nicht die ganze Landschaft.',
},
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 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.',
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 — aus Süßwasser, das unter der Erde liegt.\n\n' +
'Wenn das Meer steigt, drückt salziges 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.',
},
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!\n\n' +
'Baue rechtzeitig genug Kraftwerke (Wind oder Solar — Kohle ist billiger, aber schlecht für das Klima).',
},
cloud_seeding: {
title: '☁️ Wolken-Impfung — funktioniert das wirklich?',
text:
'Die Idee: Schiffe sprühen feinen Salznebel in Wolken. Die Wolken werden heller und reflektieren mehr Sonnenlicht zurück ins All — die Erde kühlt sich ein kleines bisschen ab.\n\n' +
'Das heißt "Marine Cloud Brightening" und gehört zum sogenannten Geo-Engineering — das sind Techniken, mit denen Menschen versuchen, das Klima direkt zu beeinflussen.\n\n' +
'In echten Experimenten (z. B. am Great Barrier Reef in Australien seit 2020) zeigte sich: Die Wirkung ist sehr klein. Niemand weiß genau, ob es gefährlich ist — vielleicht ändert sich dadurch auch der Regen an anderer Stelle.\n\n' +
'Wichtig: Geo-Engineering ersetzt NICHT, dass wir weniger CO₂ ausstoßen. Es ist keine Ausrede, so weiterzumachen wie bisher.',
hint: 'In dieser Simulation ist Wolken-Impfung teuer und bringt kaum CO₂-Reduktion — genau so wie in der Realität.',
},
coal_power: {
title: '🏭 Kohlekraftwerk — schnell und billig, aber teuer fürs Klima',
text:
'Kohlekraftwerke verbrennen Stein- oder Braunkohle, erhitzen Wasser zu Dampf und treiben damit Turbinen an. Das liefert sehr viel Strom auf einmal — und das Gebäude ist vergleichsweise günstig zu bauen.\n\n' +
'Der Haken: Beim Verbrennen entsteht extrem viel CO₂. Pro Kilowattstunde Strom aus Kohle ca. 800-1000 Gramm CO₂ — über zehnmal mehr als Solar- oder Windstrom.\n\n' +
'In Österreich wurde 2020 das letzte Kohlekraftwerk (Mellach) abgeschaltet — als erstes Land in Europa mit Ambitionen, vollständig ohne Kohle auszukommen. Deutschland will bis 2038 aussteigen.',
hint: 'Jede Kohle-Entscheidung heute bindet CO₂ für 30-40 Jahre (die Laufzeit eines Kraftwerks). Darum lohnt sich der schnelle Umstieg auf Erneuerbare.',
},
aviation: {
title: '✈️ Flugverkehr — Tourismus vs. Klima',
text:
'Ein Flugzeug stößt pro Passagier und Kilometer ca. 200-250 Gramm CO₂ aus. Ein Flug WienNew York (ca. 6.800 km) verursacht etwa 1,2 Tonnen CO₂ pro Person — mehr als viele Menschen weltweit im ganzen Jahr verbrauchen.\n\n' +
'Tourismus bringt deiner Insel zwar Einnahmen, aber die Flüge belasten das Klima. Außerdem: Wenn der Meeresspiegel steigt und der Strand verschwindet, bleiben die Touristen weg — die Einnahmen brechen dann weg.\n\n' +
'Alternativen: Nachhaltiger Tourismus mit Zug/Schiff, regionale Gäste, Wander- und Rad-Urlaub statt Flugreisen.',
hint: 'Ein einziger Langstreckenflug verursacht oft mehr CO₂ als ein ganzes Jahr Auto-Fahren.',
},
// Tooltipps (Kurzdefs wie in TIPS aus der 3D-Version)
ppm: {
title: 'ppm',
text: '„parts per million" — wie viele CO₂-Teilchen unter einer Million Luft-Teilchen sind. 425 ppm = 425 CO₂-Teilchen unter einer Million. Früher waren es 280, ab 500 wird es gefährlich.',
},
celsius: {
title: '°C',
text: 'Grad Celsius. Die globale Durchschnittstemperatur der Erde. Vorindustriell: 15 °C. Pariser Klimaziel: unter 17 °C halten.',
},
paris_goal: {
title: 'Pariser Klimaziel',
text: '2015 haben fast alle Länder der Welt in Paris versprochen, die Erderwärmung auf höchstens 2 °C über dem vorindustriellen Niveau zu begrenzen — besser sogar auf 1,5 °C. In deiner 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 €.',
},
};
/* ============================================================
GRAPH CONFIGS (Zonen + Ref-Linien wie 3D-Version)
============================================================ */
const GRAPH_CONFIGS = {
co2: {
field: 'co2', color: 'var(--ggs-coral)', unit: 'ppm', fmt: v => v.toFixed(0) + ' ppm',
yMin: 380, yMax: 700,
zones: [
{ from: 380, to: 450, color: 'rgba(90,138,94,.18)', label: 'Klimaziel' },
{ from: 450, to: 550, color: 'rgba(232,131,58,.18)', label: 'Kritisch' },
{ from: 550, to: 700, color: 'rgba(200,92,74,.18)', label: 'Gefahr' },
],
lines: [{ at: 450, color: 'rgba(100,60,40,.8)', label: 'Ziel', dashed: true }],
},
temperature: {
field: 'temperature', color: 'var(--ggs-orange)', unit: '°C', fmt: v => v.toFixed(1) + ' °C',
yMin: 15, yMax: 19,
zones: [
{ from: 15, to: 16, color: 'rgba(90,138,94,.18)', label: 'sicher' },
{ from: 16, to: 17, color: 'rgba(232,200,80,.18)', label: '+1 °C' },
{ from: 17, to: 18, color: 'rgba(232,131,58,.22)', label: '+2 °C (Paris)' },
{ from: 18, to: 19, color: 'rgba(200,92,74,.25)', label: 'Gefahr' },
],
lines: [{ at: 17, color: '#b04a3a', label: 'Klimaziel 17 °C', dashed: false }],
},
budget: {
field: 'budget', color: 'var(--ggs-moss)', unit: 'Mio €', fmt: v => Math.round(v) + ' Mio €',
yMin: -200, yMax: 1000, autoScale: true,
zones: [{ from: -200, to: 0, color: 'rgba(200,92,74,.18)', label: 'Pleite' }],
lines: [{ at: 0, color: '#b04a3a', label: '0', dashed: true }],
},
sealevel: {
field: 'sealevel', color: 'var(--ggs-fjord)', unit: 'cm', fmt: v => '+' + Math.round(v) + ' cm',
yMin: 0, yMax: 120,
zones: [
{ from: 0, to: 30, color: 'rgba(90,138,94,.15)', label: 'Strand ok' },
{ from: 30, to: 60, color: 'rgba(232,200,80,.18)', label: 'Strand weg' },
{ from: 60, to: 100, color: 'rgba(232,131,58,.22)', label: 'Häuser!' },
{ from: 100, to: 120, color: 'rgba(200,92,74,.25)', label: 'Katastrophe' },
],
lines: [
{ at: 30, color: 'var(--ggs-sand-dark)', label: 'Strand', dashed: true },
{ at: 60, color: 'var(--ggs-coral)', label: 'Häuser', dashed: true },
],
},
};
/* ============================================================
REFLECTION QUESTIONS
============================================================ */
const REFLECTION_QUESTIONS = {
1: { q:'Was war deine wirksamste Entscheidung im Level "Lernen"?',
options:['Windpark gebaut','Viele Wälder gepflanzt','Deich gebaut','Ich bin mir nicht sicher'] },
2: { q:'Welche Maßnahme hat dich im Level "Üben" am meisten überrascht?',
options:['Wie teuer Wartung auf Dauer wird','Wie schnell der Meeresspiegel steigt','Wie wenig Wolken-Impfung bringt','Wie stark Kohle das Klima belastet'] },
3: { q:'Nach drei Durchgängen — was nimmst du mit?',
options:['Frühzeitig handeln spart Geld','Mischung aus Schutz und Klimaschutz ist wichtig','Tourismus und Flughafen sind keine Lösung','Es gibt keine einfache Antwort'] }
};
/* ============================================================
STATE
============================================================ */
// Engine hält Simulations-State. Hier werden nur View-spezifische Flags
// (phase, speed, MS_PER_TICK, Tutorial, pendingCitizenEvent) ergänzt.
// `let state` statt const, damit startGame() / tryResume() die Engine-Instanz
// komplett ersetzen können, ohne View-Referenzen ins Leere laufen zu lassen.
let state = KlimaEngine.createGame(1);
// Live-View für Lehrkräfte: kompakter State-Snapshot (max 1-2 KB).
// Wird vom Plattform-live-client.js alle ~4s an /api/live gesendet.
window.GGS_LIVE_STATE = function () {
if (!state) return null;
return {
tick: state.tick,
year: (state.startYear || 2025) + (state.tick || 0),
level: state.difficulty || state.level || null,
phase: state.phase || null,
budget: Math.round(state.budget || 0),
population: Math.round(state.population || 0),
co2: Math.round(state.co2Ppm || 0),
temp: +(state.currentTemp || 0).toFixed(2),
floodedPct: Math.round(state.floodedPct || 0),
speed: state.speed || 0,
measures: Array.isArray(state.actionLog) ? state.actionLog.filter(a=>a.action==='buy').length : 0,
levelWon: !!state.levelWon,
// Laufender Punktestand (0100, gleiche Formel wie am Ende) → Ampelfarbe im
// Lehrer-Live (Tag-Ansicht färbt laufende Balken danach ein).
score: _liveKlimaScore(),
leistung_pct:_liveKlimaScore(),
};
};
// Live-Score aus dem aktuellen Stand — nur während des Spiels sinnvoll.
function _liveKlimaScore() {
if (!state || !state.diff) return null;
if (state.phase !== 'playing' && state.phase !== 'citizen' && state.phase !== 'end') return null;
var sp = state.diff.startPopulation || 6000;
var tempScore = Math.max(0, Math.min(35, 35 - ((state.currentTemp||0) - 15.5) * 20));
var floodScore = Math.max(0, 25 * (1 - (state.floodedPct||0) / 50)); // Ziel < 40 %: bei 40 % noch ~5 Pkt
var budgetScore = Math.max(0, Math.min(20, (state.budget||0) / 10));
var popScore = Math.max(0, Math.min(20, ((state.population||0) / sp) * 20));
return Math.round(tempScore + floodScore + budgetScore + popScore);
}
Object.assign(state, {
phase: 'level-select',
speed: 1, lastTickMs: 0,
MS_PER_TICK: 8000,
animMs: 0, // akkumulierte Anim-Zeit (Pause-sensitiv)
tutorialStep: 0, tutorialActive: false,
pendingCitizenEvent: null,
});
/* ============================================================
UI HELPERS
============================================================ */
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
function fmtMoney(v) { return Math.round(v) + ' Mio €'; }
function fmtPop(v) { return Math.round(v).toLocaleString('de-AT'); }
function fmtPpm(v) { return v.toFixed(0) + ' ppm'; }
function fmtTemp(v) { return v.toFixed(1) + ' °C'; }
function fmtSea(v) { return '+' + Math.round(v) + ' cm'; }
function fmtPct(v) { return v.toFixed(0) + ' %'; }
/* Overlay-Show/Hide mit Timeout-Cancellation: ein laufender Hide-Timeout
wird durch einen nachfolgenden showOverlay abgebrochen, sonst würde er
das gerade geöffnete Overlay 300 ms später wieder auf display:none setzen. */
const _overlayHideTimeouts = {};
function showOverlay(id) {
const el = $('#'+id); if (!el) return;
if (_overlayHideTimeouts[id]) { clearTimeout(_overlayHideTimeouts[id]); _overlayHideTimeouts[id] = null; }
el.style.display = '';
requestAnimationFrame(() => el.classList.add('visible'));
}
function hideOverlay(id) {
const el = $('#'+id); if (!el) return;
el.classList.remove('visible');
if (_overlayHideTimeouts[id]) clearTimeout(_overlayHideTimeouts[id]);
_overlayHideTimeouts[id] = setTimeout(() => {
el.style.display = 'none';
_overlayHideTimeouts[id] = null;
}, 300);
}
/**
* showEventUI(ev) — reine UI-Side-Effects (Toast + Sound + EventStack-Render).
* Erwartet, dass der Event bereits im state.events-Stapel liegt (z.B. via
* KlimaEngine.pushEvent). Wird aus consumeEngineEvents() pro Event aufgerufen.
*/
function showEventUI(ev) {
const icon = ev.icon, text = ev.text, type = ev.type || 'neutral', infoKey = ev.infoKey;
if (window.kwAudio) {
const soundMap = {
'co2': 'event-co2',
'temperature': 'event-temperature',
'sealevel': 'event-flood',
'vegetation': 'event-vegetation',
'drinking_water': 'event-water',
'glacier': 'event-glacier',
'blackout': 'event-blackout',
};
const key = infoKey && soundMap[infoKey] ? soundMap[infoKey]
: type === 'good' ? 'praise'
: type === 'bad' ? 'warning'
: null;
if (key) kwAudio.play(key);
}
const vp = $('#event-viewport');
if (!vp) return;
const n = document.createElement('div');
n.className = 'ggs-event-notification ' + type;
n.innerHTML = '<span class="ev-icon">'+icon+'</span> '+text;
n.style.animation = 'ggs-event-in 0.4s cubic-bezier(.22,1,.36,1) both, ggs-event-shrink 0.5s 10.5s cubic-bezier(.22,1,.36,1) both';
if (infoKey && INFO_TOPICS[infoKey]) {
n.style.cursor = 'pointer';
n.addEventListener('click', () => openInfoOverlay(infoKey));
}
vp.appendChild(n);
// Stapel-Obergrenze: 3 auf Desktop, 1 auf Touch-Geräten (iPad). Sobald ein
// weiterer Toast kommt, fliegt der älteste raus, damit die Canvas-Szene
// nicht zugedeckt wird.
const isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
const MAX_TOASTS = isTouch ? 1 : 3;
while (vp.childElementCount > MAX_TOASTS) {
const oldest = vp.firstElementChild;
if (!oldest) break;
oldest.remove();
}
setTimeout(() => n.remove(), 11400);
}
/**
* showEvent(icon, text, type, infoKey) — Legacy-Einstieg für View-eigene
* Ereignisse (z.B. UI-Warnungen, Save-Load-Hinweis), die NICHT über die
* Engine laufen. Pusht in state.events + ruft showEventUI auf.
*/
function showEvent(icon, text, type, infoKey) {
type = type || 'neutral';
const ev = { icon, text, type, tick: state.tick, infoKey: infoKey || null };
state.events.unshift(ev);
if (state.events.length > 40) state.events.pop();
renderEventStack();
showEventUI(ev);
}
function renderEventStack() {
const list = $('#event-stack-list');
const c = $('#event-count');
if (!list || !c) return;
c.textContent = state.events.length;
list.innerHTML = state.events.map((e, i) =>
'<div class="ggs-event-stack-item '+e.type+(e.infoKey?' has-info':'')+'" data-ev-idx="'+i+'">'
+ e.icon + ' <span style="opacity:.7;font-weight:700;">J.'+(state.startYear+e.tick)+':</span> '+e.text
+ '</div>'
).join('');
// JEDER Klick auf ein Ereignis öffnet die grosse Detail-Card in der Mitte.
list.querySelectorAll('[data-ev-idx]').forEach(el => {
const idx = parseInt(el.dataset.evIdx, 10);
el.addEventListener('click', () => openEventCard(state.events[idx]));
});
}
function toggleEventStack() { $('#event-stack').classList.toggle('open'); }
/** Öffnet eine grosse Detail-Card für ein Ereignis (aus dem Stapel) in
* der Bildschirmmitte. Zeigt Jahr, Titel, vollen Text und — wenn infoKey
* gesetzt — die kinderfreundliche Langerklärung aus INFO_TOPICS. */
function openEventCard(ev) {
if (!ev) return;
const card = $('#info-card');
const topic = ev.infoKey ? INFO_TOPICS[ev.infoKey] : null;
const yearTxt = 'Jahr ' + (state.startYear + ev.tick) + ' · Runde ' + ev.tick;
const title = topic ? topic.title : 'Ereignis';
let html = '';
html += '<div class="kw-ev-head">';
html += '<div class="kw-ev-icon">'+ev.icon+'</div>';
html += '<div><div class="kw-ev-year">'+escapeXml(yearTxt)+'</div>';
html += '<div class="kw-ev-title">'+escapeXml(title)+'</div></div>';
html += '</div>';
html += '<div class="kw-ev-text"><b>'+escapeXml(ev.text)+'</b></div>';
if (topic) {
const paragraphs = topic.text.split('\n\n').map(p => '<p>'+escapeXml(p).replace(/\n/g,'<br>')+'</p>').join('');
html += '<div class="kw-ev-text">' + paragraphs + '</div>';
if (topic.hint) html += '<div class="kw-info-hint">💡 '+escapeXml(topic.hint)+'</div>';
}
// Kein "keine Langfassung"-Hinweis mehr — fehlende Info-Topics stehen
// auf der Zentrale-Todo-Liste.
html += '<button class="ggs-btn ggs-btn-primary" onclick="hideOverlay(\'info-overlay\')">Verstanden ✓</button>';
card.className = 'ggs-overlay-card kw-info-card kw-event-card ' + (ev.type || '');
card.innerHTML = html;
showOverlay('info-overlay');
}
/** Öffnet das Info-Overlay mit einer kinderfreundlichen Erklärung. */
function openInfoOverlay(key) {
const topic = INFO_TOPICS[key];
if (!topic) return;
const card = $('#info-card');
card.className = 'ggs-overlay-card kw-info-card';
const paragraphs = topic.text.split('\n\n').map(p => '<p>'+escapeXml(p).replace(/\n/g,'<br>')+'</p>').join('');
const hint = topic.hint ? '<div class="kw-info-hint">💡 '+escapeXml(topic.hint)+'</div>' : '';
card.innerHTML = '<h3>'+escapeXml(topic.title)+'</h3>' + paragraphs + hint
+ '<button class="ggs-btn ggs-btn-primary" onclick="hideOverlay(\'info-overlay\')">Verstanden ✓</button>';
showOverlay('info-overlay');
}
function showAchievement(icon, title, subtitle) {
const container = $('#toast-container');
const t = document.createElement('div');
t.className = 'ggs-toast';
t.innerHTML = '<span class="ggs-toast-icon">'+icon+'</span><div><div class="ggs-toast-text">'+title+'</div><div class="ggs-toast-sub">'+(subtitle||'')+'</div></div>';
container.appendChild(t);
setTimeout(() => t.remove(), 4500);
}
/* ============================================================
GAME LOGIC — Thin wrappers around KlimaEngine (Single Source of
Truth). Alle Physik/Regeln/Events leben in engine.js; diese Seite
ist reine View + UI-Side-Effects.
============================================================ */
function computeTemperatureFromCO2(co2ppm) { return KlimaEngine.computeTemperatureFromCO2(co2ppm); }
function recalcEffects() { KlimaEngine.recalcEffects(state); }
function getEmissionsThisYear() { return KlimaEngine.getEmissionsThisYear(state); }
function getPowerDemand() { return KlimaEngine.getPowerDemand(state); }
/**
* Hausanzahl, die aktuell auf dem Canvas gezeigt wird. Skaliert mit
* Bevölkerung (ein Haus pro ~500 Einwohner), begrenzt durch die Anzahl
* vordefinierter Haus-Positionen. Wenn Bevölkerung zuwächst, tauchen
* Häuser Schritt für Schritt auf.
*/
function visibleHouseCount() {
return Math.min(HOUSES.length, Math.max(1, Math.ceil(state.population / 500)));
}
function computePopulationGrowth() { KlimaEngine.computePopulationGrowth(state); }
function computeClimate() { KlimaEngine.computeClimate(state); }
function getIncomeThisYear() { return KlimaEngine.getIncomeThisYear(state); }
function getYearlyBalance() { return KlimaEngine.getYearlyBalance(state); }
/** Verarbeitet Engine-Return-Werte: Events in Toast umwandeln, Achievements
* an die UI-Pipeline (Toast + Sound + Server-POST) weiterreichen. */
function consumeEngineEvents(events) {
if (!events || !events.length) return;
// Engine hat diese Events bereits in state.events gepusht — hier nur
// UI-Side-Effects (Toast, Sound), kein zweites state.events.unshift!
for (const ev of events) showEventUI(ev);
renderEventStack();
}
function consumeEngineAchievements(list) {
if (!list || !list.length) return;
for (const a of list) notifyAchievementUI(a);
}
/**
* buyMeasure(id) — UI-Wrapper um KlimaEngine.buyMeasure.
* - Fehlende Mittel → Toast + ui-error Sound
* - Negative Bilanz → confirm()-Dialog, bei OK erneut mit forceNegBalance
* - Erfolg → engine hat state mutiert; Events, Achievements, Sound, Render + Save
*/
function buyMeasure(id) {
// resolveMeta liefert das pro-Instanz-Extra, das die Engine in das Instance-
// Objekt einspreizt. In 2D wird die Canvas-Position als `pos` abgelegt;
// drawAllMeasures liest dann inst.pos.x/y.
const resolveMeta = () => ({ pos: pickBuildPosition(id) });
let res = KlimaEngine.buyMeasure(state, id, { resolveMeta });
if (!res.ok && res.reason === 'not-enough-budget') {
const m = KlimaEngine.findMeasure(id);
showEvent('❗', 'Nicht genug Budget für ' + (m ? m.name : id), 'bad');
if (window.kwAudio) kwAudio.play('ui-error');
return false;
}
if (!res.ok && res.reason === 'negative-balance') {
const ok = confirm(
'Warnung: Mit dieser Maßnahme rutscht deine Jahres-Bilanz ins Minus ' +
'(' + res.netAfter + ' Mio €/Jahr). Wartungskosten übersteigen dann die ' +
'Einnahmen — dein Budget sinkt jedes Jahr.\n\nTrotzdem kaufen?'
);
if (!ok) return false;
res = KlimaEngine.buyMeasure(state, id, { resolveMeta, forceNegBalance: true });
}
if (!res.ok) return false;
consumeEngineEvents(res.events);
consumeEngineAchievements(res.unlockedAchievements);
if (window.kwAudio) kwAudio.playForBuy(id);
renderUI();
autoSave();
return true;
}
function demolishMeasure(id) {
const res = KlimaEngine.demolishMeasure(state, id);
if (!res.ok) return false;
consumeEngineEvents(res.events);
if (window.kwAudio) kwAudio.play('demolish');
renderUI();
autoSave();
return true;
}
// eventFiredOnce wird nur noch lokal für UI-spezifische Einmalflags gebraucht.
function eventFiredOnce(id) {
if (state.firedEvents.has(id)) return true;
state.firedEvents.add(id);
return false;
}
/**
* simulateTick — UI-Wrapper um KlimaEngine.tick. Die Engine mutiert state
* und liefert {newEvents, unlockedAchievements, citizenEvent, endReason};
* die View reicht das an Toast, Sound, Server und Overlays weiter.
*/
function simulateTick() {
const res = KlimaEngine.tick(state);
// Zusätzlicher Drama-Sound bei +2 °C (Engine pusht nur den Text-Event)
for (const ev of res.newEvents) {
if (ev.infoKey === 'temperature' && ev.text && ev.text.indexOf('Pariser') !== -1) {
if (window.kwAudio) kwAudio.play('drama-paris-missed');
}
}
consumeEngineEvents(res.newEvents);
consumeEngineAchievements(res.unlockedAchievements);
if (res.citizenEvent) triggerCitizenDialog(res.citizenEvent);
// Live-Progress an Server alle 10 Ticks (UI-seitig, Engine hat state.tick
// inzwischen schon hochgezählt)
if (state.tick - state.lastProgressTick >= 10) {
state.lastProgressTick = state.tick;
reportLiveProgress();
}
if (res.endReason === 'won') endLevel(true);
else if (res.endReason === 'pleite') endLevel(false, 'pleite');
else if (res.endReason === 'ueberflutet') endLevel(false, 'ueberflutet');
else if (res.endReason === 'hitze') endLevel(false, 'hitze');
else {
updateCrisisMusic();
autoSave();
}
}
/** Wechselt die Hintergrundmusik automatisch auf „Drama", sobald die Lage
* kritisch wird (Budget negativ ODER Temperatur > 17 °C). Sobald sich die
* Lage entspannt, kommt der vorher gehörte Track zurück. Hat der Spieler
* selbst aus dem Dropdown etwas ausgewählt, wird NICHT umgeschaltet. */
function updateCrisisMusic() {
if (!window.ggsMusicSetAuto) return;
const critical = state.budget < 0 || state.currentTemp > 17 || state.floodedPct > 30;
if (critical) window.ggsMusicSetAuto('drama');
else window.ggsMusicRestoreAuto();
}
/* ============================================================
BÜRGER-EVENTS (Dialog-Overlay)
============================================================ */
const CITIZEN_EVENTS = KlimaEngine.CITIZEN_EVENTS;
// maybeCitizenEvent entfernt — Engine.tick liefert citizenEvent direkt,
// simulateTick reicht ihn an triggerCitizenDialog weiter.
/**
* Zeigt das Bürger-Dialog-Overlay. Bei Klick auf eine Option ruft
* KlimaEngine.applyCitizenChoice(state, ev.id, i) auf; Engine kümmert
* sich um State-Mutation, Events, Achievements. UI aktualisiert nur
* Overlays und löst Re-Render aus.
*/
function triggerCitizenDialog(ev) {
state.phase = 'citizen';
state.pendingCitizenEvent = ev; // UI-seitiges Flag (für Overlay-Status)
$('#citizen-char').textContent = ev.char;
$('#citizen-name').textContent = ev.name;
$('#citizen-msg').textContent = ev.msg;
const budgetEl = $('#citizen-budget');
const valEl = budgetEl.querySelector('.kw-citizen-budget-val');
valEl.textContent = Math.round(state.budget) + ' Mio €';
budgetEl.classList.toggle('low', state.budget < 100);
const box = $('#citizen-choices');
box.innerHTML = '';
ev.choices.forEach((c, i) => {
const btn = document.createElement('button');
btn.className = 'kw-choice';
btn.innerHTML = '<div class="kw-choice-title">'+c.title+'</div><div class="kw-choice-desc">'+c.desc+'</div>';
btn.onclick = () => {
const res = KlimaEngine.applyCitizenChoice(state, ev.id, i, {
resolveMeta: (id) => ({ pos: pickBuildPosition(id) }),
});
if (res.ok) {
consumeEngineEvents(res.events);
consumeEngineAchievements(res.unlockedAchievements);
}
state.pendingCitizenEvent = null;
state.phase = 'playing';
hideOverlay('citizen-overlay');
renderUI();
autoSave();
};
box.appendChild(btn);
});
showOverlay('citizen-overlay');
}
/* ============================================================
ACHIEVEMENTS
============================================================ */
/**
* notifyAchievementUI(a) — Pure UI-Side-Effects für ein bereits im Engine-
* State freigeschaltetes Achievement: Toast, Sound, Server-POST, Re-Render.
* Wird aus consumeEngineAchievements() pro unlocked-Eintrag aufgerufen.
*/
function notifyAchievementUI(a) {
if (!a) return;
showAchievement(a.icon, a.title, a.desc);
if (window.kwAudio) kwAudio.play('achievement');
if (window.__GGS__ && window.__GGS__.sessionId) {
fetch((window.__GGS__.baseUrl || '') + '/php/api/progress.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
session_id: window.__GGS__.sessionId,
sim_id: 'klima',
action:'achievement',
data: { level: state.difficulty, badge_key: a.key, badge_name: a.title, badge_icon: a.icon, badge_desc: a.desc, xp: 50 }
})
}).catch(()=>{});
}
renderAchievements();
}
/**
* unlockAchievement(key) — Wrapper für extern (endLevel etc.) ausgelöste
* Unlocks. Nutzt Engine.tryUnlockAchievement; bei Erfolg läuft die UI-
* Pipeline. Bereits freigeschaltete Achievements liefern `null`.
*/
function unlockAchievement(key) {
const a = KlimaEngine.tryUnlockAchievement(state, key);
if (a) notifyAchievementUI(a);
}
// checkRenewable100 nur noch als Wrapper (Engine prüft intern in buyMeasure
// und tick; nach extern ausgelöstem state.ownedMeasures-Umbau manuell aufrufen).
function checkRenewable100() {
KlimaEngine.checkRenewable100(state);
}
/**
* Achievements-Rendering — NUR re-rendern wenn sich der Erfolgs-Status
* wirklich geändert hat, sodass die Pop-Animation aus dem Design-System
* nicht bei jedem Tick aufs Neue ausgelöst wird. Beim Re-Render werden
* unverändert-freigeschaltete Badges mit `.kw-ach-still` markiert (die
* unterdrückt die Animation via CSS-Override).
*/
let _prevAchHash = '';
function renderAchievements(force) {
const host = $('#achievements-container');
const list = ACHIEVEMENTS.slice(0, 8);
const hash = list.map(a => state.achievements.has(a.key) ? '1' : '0').join('');
if (!force && hash === _prevAchHash) return;
const prev = _prevAchHash;
_prevAchHash = hash;
host.innerHTML = '';
list.forEach((a, i) => {
const has = state.achievements.has(a.key);
const wasBefore = prev.length > i ? prev[i] === '1' : false;
const justUnlocked = has && !wasBefore && prev !== '';
const el = document.createElement('div');
// Bestehende (nicht frisch freigeschaltete) Badges bekommen .kw-ach-still
// → Animation wird via CSS unterdrückt.
const classes = ['ggs-badge-earned'];
if (has && !justUnlocked) classes.push('kw-ach-still');
if (!has) classes.push('kw-ach-locked');
el.className = classes.join(' ');
el.title = a.title + (has ? ' — ' + a.desc : ' (noch nicht)');
el.dataset.achTitle = a.title;
el.dataset.achDesc = has ? a.desc : 'Noch nicht freigeschaltet.';
el.textContent = has ? a.icon : '?';
host.appendChild(el);
});
}
/* Achievement-Tap-Tooltip — title greift auf Touch nicht. Tap auf eine
Badge zeigt einen kleinen schwarzen Tooltip mit Titel + Beschreibung,
verschwindet nach 4 s oder beim nächsten Tap außerhalb. */
let _achTooltip = null;
let _achTooltipTimer = 0;
function _hideAchTooltip() {
if (_achTooltipTimer) { clearTimeout(_achTooltipTimer); _achTooltipTimer = 0; }
if (_achTooltip) { _achTooltip.remove(); _achTooltip = null; }
}
document.addEventListener('click', (e) => {
if (_achTooltip && !_achTooltip.contains(e.target) && !e.target.closest('.ggs-badge-earned')) {
_hideAchTooltip();
}
const badge = e.target.closest('.ggs-badge-earned');
if (!badge) return;
const title = badge.dataset.achTitle || badge.title || '';
const desc = badge.dataset.achDesc || '';
if (!title) return;
_hideAchTooltip();
const t = document.createElement('div');
t.className = 'kw-ach-tooltip';
t.innerHTML = '<strong>' + title.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c])) + '</strong>'
+ (desc ? '<br>' + desc.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c])) : '');
document.body.appendChild(t);
_achTooltip = t;
const rect = badge.getBoundingClientRect();
const tw = t.offsetWidth;
const th = t.offsetHeight;
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - tw / 2, window.innerWidth - tw - 8));
// Bevorzugt unter der Badge; falls dort kein Platz, nach oben.
let top = rect.bottom + 8;
if (top + th > window.innerHeight - 8) top = Math.max(8, rect.top - th - 8);
t.style.left = left + 'px';
t.style.top = top + 'px';
_achTooltipTimer = setTimeout(_hideAchTooltip, 4000);
});
/* ============================================================
UI RENDERING
============================================================ */
function renderMeasures() {
const host = $('#measures-container');
const filter = state.diff.availableMeasures;
const visible = filter ? MEASURES.filter(m => filter.indexOf(m.id) >= 0) : MEASURES;
host.innerHTML = '';
visible.forEach(m => {
const cost = Math.round(m.cost * state.researchDiscount);
const canAfford = state.budget >= cost;
const owned = state.ownedMeasures[m.id];
const count = owned ? owned.count : 0;
const card = document.createElement('div');
card.className = 'ggs-card' + (canAfford ? '' : ' unaffordable');
// Voller Beschreibungstext als title-Attribut — wird wegen line-clamp:2
// im Design-System abgeschnitten, der Browser zeigt ihn im Hover-Tooltip.
card.title = m.name + ': ' + m.description;
const glossKey = ({solar:'solaranlage', wind:'windpark', forest:'erneuerbar', mangrove:'mangrove',
dike:'deich', 'sea-wall':'kuestenschutz', coal:'kohlekraftwerk',
airport:'tourismus', 'cloud-seed':'co2_filter'})[m.id] || null;
const titleTag = glossKey ? 'data-glossar="'+glossKey+'"' : '';
const badges = [];
badges.push('<span class="ggs-badge cost" data-glossar="budget">💰 '+cost+' Mio</span>');
if (m.co2Reduction > 0) badges.push('<span class="ggs-badge effect" data-glossar="co2">🍃 '+m.co2Reduction.toFixed(2)+' ppm/J</span>');
if (m.co2Reduction < 0) badges.push('<span class="ggs-badge danger" data-glossar="co2">💨 +'+Math.abs(m.co2Reduction).toFixed(2)+' ppm/J</span>');
if (m.protection > 0) badges.push('<span class="ggs-badge protect" data-glossar="kuestenschutz">🛡️ +'+m.protection+' cm</span>');
if (m.powerOutput) badges.push('<span class="ggs-badge protect" data-glossar="strom">⚡ +'+m.powerOutput+' MW</span>');
if (m.upkeep > 0) badges.push('<span class="ggs-badge cost" data-glossar="wartung">⚙ '+m.upkeep+' Mio/J</span>');
card.innerHTML =
'<div class="ggs-card-header">'
+ '<span class="ggs-card-icon">'+m.emoji+'</span>'
+ '<span class="ggs-card-title" '+titleTag+'>'+m.name+'</span>'
+ (count > 0 ? '<span class="ggs-card-count">×'+count+'</span>' : '')
+ (count > 0 ? '<button class="ggs-card-demolish" data-demolish="'+m.id+'" title="Abreißen (+'+Math.round(cost*0.5)+' Mio €)">🗑</button>' : '')
+ '</div>'
+ '<div class="ggs-card-desc">'+m.description+'</div>'
+ '<div class="ggs-card-badges">'+badges.join('')+'</div>';
// Bereits gebaute Maßnahmen: grüner Ring (auf iPad sichtbarer Marker)
if (count > 0) card.classList.add('kw-card-owned');
card.addEventListener('click', (e) => {
if (e.target.closest('[data-demolish]')) return;
if (!canAfford) { showEvent('💸', 'Zu teuer — spare Budget oder baue weniger.', 'bad'); return; }
// Touch-Spalte ist im Expand-Modus → Bau direkt auslösen, Spalte
// klappt automatisch zurück zur Icon-Variante.
collapseBuildPanel();
card.classList.add('kw-card-selected');
setTimeout(() => card.classList.remove('kw-card-selected'), 350);
buyMeasure(m.id);
});
host.appendChild(card);
});
$$('[data-demolish]').forEach(b => {
b.addEventListener('click', (e) => {
e.stopPropagation();
demolishMeasure(b.dataset.demolish);
});
});
}
/* Expand/Collapse-Logik der iPad-Bau-Spalte. Default: schmal mit Icons.
Tap auf den -Toggle oben → Spalte schiebt sich als Overlay über den
Canvas und zeigt die volle Desktop-Card-Variante. Tap auf eine Karte
(Bauen) oder erneuter Toggle-Tap → klappt wieder zur Icon-Spalte. */
function expandBuildPanel() {
const z = $('#zone-actions'); if (!z) return;
z.classList.add('kw-build-expanded');
const t = $('#kw-build-toggle'); if (t) { t.classList.add('is-open'); t.textContent = '×'; }
}
function collapseBuildPanel() {
const z = $('#zone-actions'); if (!z) return;
z.classList.remove('kw-build-expanded');
const t = $('#kw-build-toggle'); if (t) { t.classList.remove('is-open'); t.textContent = ''; }
}
(function setupBuildToggle() {
const t = document.getElementById('kw-build-toggle');
if (!t) return;
// Toggle nur auf Touch sichtbar; Desktop sieht ihn nie.
function refresh() {
const isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
t.style.display = isTouch ? '' : 'none';
}
refresh();
if (window.matchMedia) {
window.matchMedia('(pointer: coarse)').addEventListener('change', refresh);
}
t.addEventListener('click', () => {
const z = document.getElementById('zone-actions');
if (z && z.classList.contains('kw-build-expanded')) collapseBuildPanel();
else expandBuildPanel();
});
})();
function renderStatus() {
// 3 Hauptwerte (CO₂/Temp/Meer stehen NUR in den Graphen)
document.querySelector('[data-stat="budget"]').textContent = fmtMoney(state.budget);
document.querySelector('[data-stat="population"]').textContent = fmtPop(state.population);
const demand = getPowerDemand();
document.querySelector('[data-stat="power"]').textContent = demand + '/' + state.powerOutput + ' MW';
// Budget-Bilanz (wie 3D-Version: Steuern / Tourismus / Wartung / Klima-Schaden / Netto)
const bal = getYearlyBalance();
document.querySelector('[data-stat="b-income"]').textContent = bal.income + ' Mio €';
const bRowT = document.querySelector('.b-tourism-row');
const bNumT = document.querySelector('.b-tourism-num');
if (bal.tourism > 0) { bRowT.style.display = ''; bNumT.style.display = ''; bNumT.textContent = bal.tourism + ' Mio €'; }
else { bRowT.style.display = 'none'; bNumT.style.display = 'none'; }
document.querySelector('[data-stat="b-upkeep"]').textContent = Math.round(bal.upkeep) + ' Mio €';
const bRowC = document.querySelector('.b-climate-row');
const bNumC = document.querySelector('.b-climate-num');
if (bal.climate > 0) { bRowC.style.display = ''; bNumC.style.display = ''; bNumC.textContent = bal.climate + ' Mio €'; }
else { bRowC.style.display = 'none'; bNumC.style.display = 'none'; }
const netEl = document.querySelector('[data-stat="b-net"]');
netEl.textContent = (bal.net >= 0 ? '+' : '') + bal.net + ' Mio €';
netEl.className = bal.net >= 0 ? 'pos' : 'neg';
// Strom-Netz: zwei Balken auf gemeinsamer Skala (Kap. vs. Bedarf)
const cap = state.powerOutput;
const scale = Math.max(8, Math.max(cap, demand) + 2);
const capPct = Math.min(100, (cap / scale) * 100);
const demPct = Math.min(100, (demand / scale) * 100);
const ok = cap >= demand;
const capFill = document.querySelector('[data-pg="cap"]');
const demFill = document.querySelector('[data-pg="dem"]');
capFill.style.width = capPct + '%';
demFill.style.width = demPct + '%';
capFill.classList.toggle('low', !ok);
document.querySelector('[data-pg="cap-num"]').textContent = cap + ' MW';
document.querySelector('[data-pg="dem-num"]').textContent = demand + ' MW';
const statusEl = document.querySelector('[data-pg="status"]');
statusEl.textContent = ok ? 'Versorgung gesichert' : 'Stromausfall — zu wenig!';
statusEl.classList.toggle('ok', ok);
statusEl.classList.toggle('bad', !ok);
// Topbar
document.querySelector('[data-stat="year"]').textContent = 'Jahr ' + (state.startYear + state.tick);
document.querySelector('[data-stat="tick"]').textContent = 'Runde ' + state.tick + ' / ' + state.maxTick;
document.querySelector('[data-stat="weather"]').textContent = weatherLabel();
document.querySelector('[data-stat="income-line"]').textContent = '+' + bal.income + ' Mio/Jahr';
}
function weatherLabel() {
if (state.currentTemp > 17.5) return 'Hitzewelle';
if (state.currentTemp > 16.8) return 'Heiß';
if (state.currentTemp > 16.2) return 'Warm';
if (state.currentTemp > 15.6) return 'Mild';
return 'Kühl';
}
function renderGoals() {
const host = $('#goals-container');
const goals = [
{ id:'survive', title:'Bis 2175 überleben', pct: Math.min(100, (state.tick / state.maxTick) * 100), done: state.tick >= state.maxTick },
{ id:'temp', title:'Temperatur < +2 °C', pct: Math.max(0, Math.min(100, (1 - (state.currentTemp - 15) / 2) * 100)), done: state.currentTemp < 17 && state.tick >= state.maxTick },
{ id:'budget', title:'Nicht pleite', pct: state.budget > 0 ? 100 : 0, done: state.budget > 0 },
{ id:'flood', title:'Stadt schützen (< 40 %)', pct: Math.max(0, 100 - state.floodedPct * 2.5), done: state.floodedPct < 40 && state.tick >= state.maxTick },
];
host.innerHTML = goals.map(g =>
'<div class="ggs-param">'
+ '<span class="ggs-param-icon">'+(g.done?'✅':'⬜')+'</span>'
+ '<span class="ggs-param-label">'+g.title+'</span>'
+ '<span class="ggs-param-value">'+Math.round(g.pct)+' %</span>'
+ '</div>'
+ '<div class="ggs-param-bar"><div class="ggs-param-fill '+(g.pct>66?'good':g.pct>33?'warning':'danger')+'" style="width:'+g.pct+'%"></div></div>'
).join('');
}
/**
* Graph-Render wie 3D-Version: jeder Graph zieht sich seine Config aus
* GRAPH_CONFIGS, rendert Zonen, Referenzlinien, Y-Achse, Datenkurve und
* einen pulsierenden Live-Punkt + Wert-Badge oben rechts.
*/
function renderGraphs() {
Object.keys(GRAPH_CONFIGS).forEach(key => drawGraph(key, GRAPH_CONFIGS[key]));
}
function getTimelineForField(field) {
return state.timeline.map(t => {
if (field === 'co2') return t.co2;
if (field === 'temperature') return t.temp;
if (field === 'budget') return t.budget;
if (field === 'sealevel') return t.sea;
return 0;
});
}
function getLiveValue(field) {
if (field === 'co2') return state.co2Ppm;
if (field === 'temperature') return state.currentTemp;
if (field === 'budget') return state.budget;
if (field === 'sealevel') return state.seaLevelCm;
return 0;
}
function drawGraph(key, cfg) {
const svg = document.querySelector('[data-graph-svg="'+key+'"]');
const axisHost = document.querySelector('[data-graph-axis="'+key+'"]');
if (!svg) return;
// Da preserveAspectRatio="none" auch Texte strecken würde, liegen
// ALLE Beschriftungen als HTML-Overlay im .ggs-graph-axis-Container.
// Das SVG enthält nur Shapes: rect (Zonen), line (Ref + Grid), path,
// polyline, circle (Live-Punkt).
const W = 200, H = 100;
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
const rawPts = getTimelineForField(cfg.field);
const liveVal = getLiveValue(cfg.field);
const pts = rawPts.map((y, i) => ({ x: i, y }));
if (pts.length === 0 || pts[pts.length - 1].x < state.tick) {
pts.push({ x: state.tick, y: liveVal });
}
let yMin = cfg.yMin, yMax = cfg.yMax;
if (cfg.autoScale && pts.length > 0) {
let maxV = -Infinity, minV = Infinity;
for (const p of pts) { if (p.y > maxV) maxV = p.y; if (p.y < minV) minV = p.y; }
if (maxV > yMax) yMax = niceCeil(maxV * 1.1);
if (minV < yMin) yMin = niceFloor(minV * 1.1);
}
const yRange = Math.max(0.01, yMax - yMin);
const yToPx = y => ((yMax - y) / yRange) * H; // 0 oben, H unten
const yToPct = y => ((yMax - y) / yRange) * 100; // 0 % oben, 100 % unten
const maxTicks = state.maxTick;
let svgHtml = '';
// Zonen (nur Rechtecke, kein Text im SVG)
for (const z of (cfg.zones || [])) {
const y1 = Math.max(0, yToPx(z.to));
const y2 = Math.min(H, yToPx(z.from));
const h = Math.max(0, y2 - y1);
if (h <= 0) continue;
svgHtml += '<rect x="0" y="'+y1.toFixed(1)+'" width="'+W+'" height="'+h.toFixed(1)+'" fill="'+z.color+'" />';
}
// Gitter-Linien (an 1/3 und 2/3)
for (let i = 1; i <= 2; i++) {
const y = (i / 3) * H;
svgHtml += '<line x1="0" y1="'+y.toFixed(1)+'" x2="'+W+'" y2="'+y.toFixed(1)+'" stroke="rgba(0,0,0,.06)" stroke-width="0.5" />';
}
// Referenzlinien
for (const l of (cfg.lines || [])) {
const y = yToPx(l.at);
if (y < 0 || y > H) continue;
const dash = l.dashed ? ' stroke-dasharray="3 3"' : '';
svgHtml += '<line x1="0" y1="'+y.toFixed(1)+'" x2="'+W+'" y2="'+y.toFixed(1)+'" stroke="'+l.color+'" stroke-width="1.1"'+dash+' />';
}
// Daten-Pfad + Live-Punkt
if (pts.length >= 1) {
const path = pts.map((p, i) => {
const x = (p.x / maxTicks) * W;
const y = Math.max(1, Math.min(H - 1, yToPx(p.y)));
return (i === 0 ? 'M' : 'L') + ' ' + x.toFixed(1) + ' ' + y.toFixed(1);
}).join(' ');
if (pts.length >= 2) {
svgHtml += '<path d="'+path+'" fill="none" stroke="'+cfg.color+'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />';
}
const last = pts[pts.length - 1];
const lx = (last.x / maxTicks) * W;
const ly = Math.max(3, Math.min(H - 3, yToPx(last.y)));
svgHtml += '<circle cx="'+lx.toFixed(1)+'" cy="'+ly.toFixed(1)+'" r="3" fill="'+cfg.color+'" opacity="0.4">'
+ '<animate attributeName="r" values="2.5;6;2.5" dur="1.8s" repeatCount="indefinite" />'
+ '<animate attributeName="opacity" values="0.45;0.05;0.45" dur="1.8s" repeatCount="indefinite" /></circle>';
svgHtml += '<circle cx="'+lx.toFixed(1)+'" cy="'+ly.toFixed(1)+'" r="2" fill="'+cfg.color+'" stroke="#fff" stroke-width="0.8" vector-effect="non-scaling-stroke" />';
}
svg.innerHTML = svgHtml;
// --- HTML-Overlay: Y-Ticks, Zonen-Labels, Referenzlinien-Labels ---
if (!axisHost) return;
let axisHtml = '';
// Y-Ticks (4 Werte: min, 1/3, 2/3, max)
const ticks = [yMax, yMin + 2*yRange/3, yMin + yRange/3, yMin];
for (const t of ticks) {
const pct = yToPct(t);
axisHtml += '<div class="ggs-graph-axis-y" style="top:'+pct.toFixed(1)+'%">'+formatTick(t)+'</div>';
}
// Zonen-Labels (linksbündig in der Mitte jeder Zone)
for (const z of (cfg.zones || [])) {
if (!z.label) continue;
const midY = (z.from + z.to) / 2;
const pct = yToPct(midY);
axisHtml += '<div class="ggs-graph-axis-zone" style="top:'+pct.toFixed(1)+'%">'+escapeXml(z.label)+'</div>';
}
// Referenz-Labels (rechts)
for (const l of (cfg.lines || [])) {
if (!l.label) continue;
const pct = yToPct(l.at);
axisHtml += '<div class="ggs-graph-axis-ref" style="top:'+pct.toFixed(1)+'%;color:'+l.color+'">'+escapeXml(l.label)+'</div>';
}
// Live-Wert als prominenter HTML-Wert oben rechts
if (pts.length >= 1) {
const last = pts[pts.length - 1];
const valText = cfg.fmt ? cfg.fmt(last.y) : last.y.toFixed(1);
axisHtml += '<div class="kw-graph-live-val" style="background:'+cfg.color+'">'+escapeXml(valText)+'</div>';
}
axisHost.innerHTML = axisHtml;
}
function formatTick(v) {
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);
}
function niceCeil(v) {
if (v <= 0) return 10;
const exp = Math.floor(Math.log10(v));
const base = Math.pow(10, exp);
const n = v / base;
const nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
return nice * base;
}
function niceFloor(v) { return v >= 0 ? 0 : -niceCeil(-v); }
function escapeXml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function renderUI() {
renderStatus();
renderMeasures();
renderGoals();
renderGraphs();
renderAchievements();
renderTimeline();
renderCanvas();
}
/**
* Zeichnet die Zeitleiste direkt unter der Topbar:
* - dünne Linie, Striche alle 10 Jahre mit Jahreszahl
* - vertikaler Now-Marker (aktuelle Runde)
* - klickbare Ereignis-Punkte aus state.events
* (Grün = good, Rot = bad, Grau = neutral → aus event.type)
* - Klick öffnet die Ereignis-Card
*
* Auch: "Jahre/Minute"-Anzeige im Header bei Speed-Änderung aktuell halten.
*/
function renderTimeline() {
const ticksEl = $('#kw-tl-ticks');
const dotsEl = $('#kw-tl-dots');
const nowEl = $('#kw-tl-now');
const rateEl = document.querySelector('[data-stat="rate"]');
if (!ticksEl || !dotsEl || !nowEl) return;
const maxTick = state.maxTick;
const year0 = state.startYear;
// Rate-Anzeige (Jahre pro Minute bei aktueller Geschwindigkeit).
if (rateEl) {
const yearsPerMin = state.speed > 0 ? (60000 / state.MS_PER_TICK * state.speed) : 0;
rateEl.textContent = (state.speed === 0 ? 'Pause' : yearsPerMin.toFixed(1).replace('.', ',') + ' J/min');
}
// Tick-Markierungen: alle 5 Jahre kleine, alle 10 Jahre große mit Jahreszahl.
// Bei maxTick=150 gibt es also 15 beschriftete Dekaden-Striche.
let ticksHtml = '';
for (let y = 0; y <= maxTick; y += 5) {
const pct = (y / maxTick) * 100;
if (y % 10 === 0) {
ticksHtml += '<div class="major" style="left:'+pct+'%"></div>';
ticksHtml += '<span class="decade" style="left:'+pct+'%">'+(year0 + y)+'</span>';
} else {
ticksHtml += '<div class="minor" style="left:'+pct+'%"></div>';
}
}
ticksEl.innerHTML = ticksHtml;
// Now-Marker
nowEl.style.left = ((state.tick / maxTick) * 100) + '%';
// Ereignis-Punkte aus state.events (neu nach alt einsortiert).
// Mehrere Events am selben Tick werden leicht gestaffelt, damit keiner
// den anderen komplett überdeckt.
const eventsByTick = {};
for (let i = 0; i < state.events.length; i++) {
const e = state.events[i];
if (!eventsByTick[e.tick]) eventsByTick[e.tick] = [];
eventsByTick[e.tick].push({ e, idx: i });
}
let dotsHtml = '';
for (const tickStr in eventsByTick) {
const group = eventsByTick[tickStr];
const pct = (parseInt(tickStr, 10) / maxTick) * 100;
group.forEach((g, i) => {
const e = g.e;
const cls = e.type === 'good' ? 'good' : e.type === 'bad' ? 'bad' : 'neutral';
const yr = year0 + e.tick;
// Tooltip: Jahr + Text (kurz)
const shortText = e.text.length > 60 ? e.text.slice(0, 58) + '…' : e.text;
const title = yr + ': ' + shortText;
// Leichter Y-Offset bei mehreren Events pro Tick (0, -2, +2 px, alternierend)
const yOffset = i === 0 ? 0 : (i % 2 === 1 ? -3 : 3) * Math.ceil(i/2);
dotsHtml += '<button class="'+cls+'" style="left:'+pct+'%;top:'+(9+yOffset)+'px" data-tl-idx="'+g.idx+'" title="'+escapeXml(title)+'"></button>';
});
}
dotsEl.innerHTML = dotsHtml;
dotsEl.querySelectorAll('button').forEach(b => {
b.addEventListener('click', () => {
const idx = parseInt(b.dataset.tlIdx, 10);
const ev = state.events[idx];
if (ev) openEventCard(ev);
});
});
}
/* ============================================================
2D CANVAS RENDERER
============================================================ */
const canvas = $('#sim-canvas');
const ctx = canvas.getContext('2d');
/**
* Canvas-Größe nur ändern, wenn sie sich wirklich verändert hat. Sonst
* bleibt der Canvas-Buffer, Transform bleibt intakt, und wir vermeiden
* das weiße Flackern, das `canvas.width = …` bei jedem Setzen auslöst.
*/
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = Math.floor(rect.width * dpr);
const h = Math.floor(rect.height * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
}
/* ============================================================
INSEL-GEOMETRIE
Welt-Koordinaten sind 0..100 (x) x 0..100 (y).
Die Insel hat mehrere Höhenstufen: Vulkan hinten, mittlere Hügel,
Flachland mit Siedlung, Strand an der Wasserlinie. Rechts und links
endet die Insel im Meer.
============================================================ */
const ISLAND = {
// Canvas-Proportionen (Welt-Y 0..100):
// Himmel 0..22, Vulkan 22..60 (endet an der hintersten Hügelreihe = Horizont),
// Hügel 54..84, Strand 85..89, Wasser 89..100.
// Der Vulkan sitzt hinten als ferner, leicht verblasster Berg — seine Basis
// geht bis zur ersten Hügelreihe (y=58), seine Breite entspricht ungefähr
// der hintersten Hügelreihe, und sein Grau ist durch Luftperspektive
// heller/bläulicher als Nahtöne.
yVolcanoPeak: 22,
yVolcanoBase: 60,
yBeach: 85,
ySeaBaseline: 89,
};
/** Halbe Insel-Breite (Welt-Koords) an Höhe y.
* Strand fast bildschirmbreit (±48), Berg schmaler (±22). */
function islandHalfWidth(y) {
if (y < ISLAND.yVolcanoPeak) return 0;
if (y > ISLAND.yBeach) return 48;
const t = (y - ISLAND.yVolcanoPeak) / (ISLAND.yBeach - ISLAND.yVolcanoPeak);
return 22 + t * 26;
}
/** Gibt gültiges x-Intervall (Welt-Koords) an Höhe y zurück. Insel-Mitte = x=50. */
function islandXRange(y) {
const hw = islandHalfWidth(y);
return { min: 50 - hw, max: 50 + hw };
}
/* ============================================================
HÜGEL-LANDSCHAFT — gestaffelte Kuppeln im Flat-Illustration-Stil
Jeder Layer ist eine horizontale Reihe überlappender Halbellipsen
(Kuppeln) mit Licht-Gradient. Von hinten (hellgrün) bis vorne
(dunkelgrün), dann beige/sandfarbene Kuppeln an der Küstenlinie.
Positionen werden deterministisch aus einem Seed berechnet,
damit die Insel bei jedem Frame gleich aussieht.
============================================================ */
// Hügel-Reihen: niedrig (h = ~4 Welt-Einheiten), eng gestaffelt, alle
// innerhalb der unteren ~15 Welt-Einheiten. Hinten hellstes Limette, vorne
// dunkles Waldgrün. Count passt so, dass sich die Kuppeln horizontal
// überlappen, aber nicht zu viele Einheiten am Rand entstehen.
// Hügel-Reihen: breiter gestaffelt von y=64 bis y=82, gut höher als vorher.
// Hinten hell-limette, vorne dunkles Waldgrün. Die Y-Abstände sind grösser,
// damit die Insel-Zone mehr Vertikalfläche füllt.
// 7 Hügelreihen über y=60..83 (23 Welt-Einheiten). Hinten schmaler (zum
// Vulkan hin), mittlere Reihen deutlich breiter (die Insel „geht auseinander"),
// vorne am Strand wieder etwas schmaler. Mehr Reihen verhindern sichtbare
// Unterbrüche zwischen den Kuppeln.
// - `spread`: horizontaler Welt-Bereich, über den sich die Hügel verteilen
// (pro Layer eigene Breite → Ei-/Sanduhr-Silhouette).
// Die hinterste Reihe umfasst den Vulkanfuß (spread ≈ Vulkan-Basisbreite + Puffer),
// damit der Vulkan visuell in die Insel eingebettet ist und nicht „schwebt".
// Danach wird es in der Mitte breiter (Ei-Form), vorne wieder etwas zusammen.
const HILL_LAYERS = [
{ y: 60, spread: 58, count: 9, colLight: '#d8f0a4', colDark: '#b3dc80', w: 22, h: 10, seed: 101 }, // umschließt Vulkanfuß
{ y: 65, spread: 70, count: 9, colLight: '#cde898', colDark: '#a6d074', w: 22, h: 11, seed: 207 },
{ y: 70, spread: 80, count: 10, colLight: '#bcdf88', colDark: '#9ac670', w: 22, h: 11, seed: 313 },
{ y: 74, spread: 84, count: 11, colLight: '#a9cc7a', colDark: '#80ac5a', w: 22, h: 11, seed: 419 }, // Mitte breit
{ y: 78, spread: 82, count: 10, colLight: '#8eb862', colDark: '#61923e', w: 22, h: 11, seed: 523 },
{ y: 81, spread: 80, count: 10, colLight: '#79a650', colDark: '#487a2c', w: 22, h: 11, seed: 619 },
{ y: 84, spread: 78, count: 10, colLight: '#5f9240', colDark: '#356428', w: 22, h: 10, seed: 727 }, // vorne
];
const BEACH_LAYERS = [
{ y: 86.5, spread: 78, count: 8, colLight: '#e8cf92', colDark: '#c6a66a', w: 22, h: 6, seed: 811 },
{ y: 88.5, spread: 76, count: 8, colLight: '#f2dfa8', colDark: '#d4b57e', w: 22, h: 5, seed: 907 },
];
/** Deterministischer Pseudo-Random aus seed. */
function seededRand(seed) {
let s = seed | 0;
return function () {
s = (s * 9301 + 49297) | 0;
s = s % 233280;
return (s < 0 ? s + 233280 : s) / 233280;
};
}
/** Zeichnet eine Hügel-Kuppel (Halbellipse) mit Licht-Gradient. */
function drawHillDome(cx, yBase, halfW, height, colLight, colDark) {
ctx.save();
// Gradient: oben hell (Sonnenlicht), unten dunkler
const g = ctx.createLinearGradient(cx, yBase - height, cx, yBase);
g.addColorStop(0, colLight);
g.addColorStop(1, colDark);
ctx.fillStyle = g;
ctx.beginPath();
// Oberer Halbkreis: 9-Uhr gegen Uhrzeigersinn zu 3-Uhr über 12-Uhr (oben)
ctx.ellipse(cx, yBase, halfW, height, 0, Math.PI, 2 * Math.PI, false);
ctx.closePath();
ctx.fill();
ctx.restore();
}
/**
* Zeichnet eine ganze Reihe gestaffelter Kuppeln. Die Kuppeln werden
* deterministisch (seed) mit leicht variierten Breiten/Höhen gesetzt,
* sodass sie sich überlappen.
*/
function drawHillLayer(layer, pxX, pxY, W, H) {
const rng = seededRand(layer.seed);
const yBase = pxY(layer.y);
const n = layer.count;
// Horizontaler Wirkungsbereich pro Layer (Ei-/Sanduhr-Silhouette).
// spread = Welt-Einheiten; zentriert um x=50.
const spread = layer.spread != null ? layer.spread : 84;
const marginW = (100 - spread) / 2;
const centerX = 50;
const halfSpread = spread / 2;
for (let i = 0; i < n; i++) {
const t = n > 1 ? i / (n - 1) : 0.5;
const baseX = marginW + t * spread;
const jitter = (rng() - 0.5) * (spread * 0.04);
const cxW = baseX + jitter;
// Edge-Fade relativ zur Layer-Breite: Hügel an den Enden kleiner.
const dist = halfSpread > 0 ? Math.abs(cxW - centerX) / halfSpread : 0;
const edge = Math.max(0.15, Math.cos(Math.min(1, dist) * Math.PI / 2));
const hw = (layer.w * (0.82 + rng() * 0.38) * edge) / 2;
const h = layer.h * (0.80 + rng() * 0.40) * edge;
const cx = pxX(cxW);
const halfWpx = (hw / 100) * W;
const heightPx = (h / 100) * H;
drawHillDome(cx, yBase, halfWpx, heightPx, layer.colLight, layer.colDark);
}
}
/* ============================================================
BAU-ZONEN pro Maßnahme (Welt-Koords).
yMin/yMax: erlaubtes Höhen-Band. xSpread: Anteil der Insel-Breite,
in dem die Maßnahme platziert werden darf (1.0 = ganze Breite auf der
jeweiligen Höhe, 0.6 = nur zentrale 60 %).
============================================================ */
// Bauzonen angepasst an die 7-Reihen-Landschaft:
// Vulkan (Hintergrund) y=22..60, Hügel y=62..84, Strand y=86..89.
// Kraftwerke stehen auf den hintersten Hügeln (nahe Vulkanfuß). Flughafen
// und Kohle brauchen breitere Fläche → mittleren Hügeln. Wald + Häuser
// überall verteilt. Radwege im mittleren Grün.
const MEASURE_ZONES = {
// Wolken-Impfung = Marine Cloud Brightening: Spezialschiff vor der Küste.
// Y-Position wird beim Rendering dynamisch an die aktuelle Wasserlinie
// gebunden (siehe drawCanvas Schritt 11). Die yMin/yMax-Werte sind nur
// Platzhalter für pickBuildPosition.
'cloud-seed': { yMin: 92, yMax: 95, xSpread: 1.0, sea: true },
'wind': { yMin: 60, yMax: 68, xSpread: 0.5 }, // hintere Hügel
'coal': { yMin: 62, yMax: 68, xSpread: 0.45 }, // nahe Vulkanfuß
'airport': { yMin: 68, yMax: 74, xSpread: 0.55 }, // mittel-hinten (braucht Breite)
'forest': { yMin: 58, yMax: 83, xSpread: 0.95 }, // überall — auch ganz hinten auf den hintersten Hügeln am Vulkanfuß
'solar': { yMin: 70, yMax: 80, xSpread: 0.8 }, // mittlere Flanken
'green-roof': { yMin: 66, yMax: 82, xSpread: 0.7 }, // überall bei Häusern
'bikes': { yMin: 72, yMax: 82, xSpread: 0.8 }, // mittleres+vorderes Grün
'mangrove': { yMin: 87, yMax: 88, xSpread: 1.0, edge: true },
'sand-fill': { yMin: 87, yMax: 89, xSpread: 1.0, edge: true, canFlood: true },
'dike': { yMin: 86, yMax: 88, xSpread: 1.0, edge: true },
'sea-wall': { yMin: 85, yMax: 86, xSpread: 1.0, edge: true },
};
/**
* Wählt eine zufällige, NICHT im Wasser liegende Welt-Position für eine
* Maßnahme innerhalb ihrer Zone. Berücksichtigt die Insel-Silhouette:
* auf Bergebenen schmaler als an der Wasserlinie.
*/
function pickBuildPosition(id) {
const zone = MEASURE_ZONES[id];
if (!zone) return { x: 50, y: 70 };
const y = zone.yMin + Math.random() * (zone.yMax - zone.yMin);
if (zone.sky || zone.sea) {
// Himmel und Meer: horizontal frei (nicht an Insel-Silhouette gebunden)
return { x: 6 + Math.random() * 88, y };
}
const range = islandXRange(y);
const center = (range.min + range.max) / 2;
const span = (range.max - range.min) * zone.xSpread;
const x = center - span / 2 + Math.random() * span;
return { x, y };
}
/* ============================================================
WOLKEN-SYSTEM (driftet sanft nach rechts)
============================================================ */
const CLOUDS = [];
function seedClouds(count) {
CLOUDS.length = 0;
for (let i = 0; i < count; i++) {
CLOUDS.push({
x: Math.random() * 120 - 10, // Welt-x (etwas Überhang)
y: 6 + Math.random() * 14, // obere Himmelshälfte
r: 0.9 + Math.random() * 0.6, // Größen-Multiplikator
speed: 0.012 + Math.random() * 0.018, // Welt-Einheiten pro Frame
});
}
}
seedClouds(5);
function updateClouds() {
// Pause (Speed 0 oder Phase != playing) Wolken stehen. Speedstufen >1
// beschleunigen die Drift proportional.
const mult = (state.phase === 'playing' && state.speed > 0) ? state.speed : 0;
if (mult === 0) return;
for (const c of CLOUDS) {
c.x += c.speed * mult;
if (c.x > 115) c.x = -12 + Math.random() * 5;
}
}
/* ============================================================
SCHIFFE AUF DEM MEER — Fischerboot (nah, groß), Tanker (fern, groß aber
kleiner wirkend), Segelschiff (mittelnah, zierlich). Unterschiedliche
Geschwindigkeiten und y-Positionen simulieren Entfernung.
============================================================ */
const BOAT = { x: 15, y: 86, dir: 1, speed: 0.06 };
// Fernschiffe am fernen Horizont (Übergang ferne See → Himmel). Sitzen
// unterhalb des Vulkan-Gletschers, auf der Höhe wo Vulkan-Flanken in die
// hinterste Hügelreihe übergehen. Klein skaliert (Distanz).
const TANKER = { x: 80, y: 57, dir: -1, speed: 0.020, scale: 0.42 };
const SAILR = { x: 35, y: 58, dir: 1, speed: 0.028, scale: 0.35 };
// Vorderes Segelboot (nah) — fährt neben dem Motorboot auf dem Meer,
// etwa gleich groß, aber halb so schnell. Zufällige Startrichtung für
// variable Begegnungen mit dem Motorboot.
const SAILB = { x: 62, y: 86, dir: -1, speed: 0.015, scale: 1.0 };
function _animMult() {
return (state.phase === 'playing' && state.speed > 0) ? state.speed : 0;
}
function updateBoat() {
const m = _animMult(); if (m === 0) return;
BOAT.x += BOAT.dir * BOAT.speed * m;
if (BOAT.x > 95) { BOAT.dir = -1; BOAT.x = 95; }
if (BOAT.x < 5) { BOAT.dir = 1; BOAT.x = 5; }
}
function updateFarShips() {
const m = _animMult(); if (m === 0) return;
TANKER.x += TANKER.dir * TANKER.speed * m;
if (TANKER.x > 98) { TANKER.dir = -1; TANKER.x = 98; }
if (TANKER.x < 2) { TANKER.dir = 1; TANKER.x = 2; }
SAILR.x += SAILR.dir * SAILR.speed * m;
if (SAILR.x > 97) { SAILR.dir = -1; SAILR.x = 97; }
if (SAILR.x < 3) { SAILR.dir = 1; SAILR.x = 3; }
}
/** Vorderes Segelboot: halbe Geschwindigkeit des Motorboots, variable
* Richtung. Damit sich die beiden Boote nicht synchron bewegen, wechselt
* das Segelboot am Umkehrpunkt zufällig die Pause-Geschwindigkeit. */
function updateFrontSail() {
const m = _animMult(); if (m === 0) return;
SAILB.x += SAILB.dir * SAILB.speed * m;
if (SAILB.x > 92) { SAILB.dir = -1; SAILB.x = 92; SAILB.speed = 0.012 + Math.random() * 0.010; }
if (SAILB.x < 8) { SAILB.dir = 1; SAILB.x = 8; SAILB.speed = 0.012 + Math.random() * 0.010; }
}
/** Großer Öltanker: langgestreckter dunkler Rumpf, kleiner Aufbau hinten,
* drei weiße Öltanks auf Deck. Scale (0..1) reduziert alle Maße. */
function drawTanker(x, y, scale, facingLeft) {
ctx.save();
if (facingLeft) { ctx.translate(x, y); ctx.scale(-1, 1); ctx.translate(-x, -y); }
const s = scale;
// Rumpf (breit, schwarz-blau)
ctx.fillStyle = '#2a3440';
ctx.beginPath();
ctx.moveTo(x - 34*s, y);
ctx.lineTo(x + 30*s, y);
ctx.quadraticCurveTo(x + 36*s, y + 3*s, x + 30*s, y + 6*s);
ctx.lineTo(x - 32*s, y + 6*s);
ctx.quadraticCurveTo(x - 36*s, y + 3*s, x - 34*s, y);
ctx.closePath();
ctx.fill();
// Rote Wasserlinie
ctx.fillStyle = '#8a3830';
ctx.fillRect(x - 33*s, y + 2*s, 64*s, 1*s);
// Drei Öltanks
ctx.fillStyle = '#d5d5d5';
for (let i = -2; i <= 2; i += 2) {
ctx.beginPath();
ctx.arc(x + i*7*s, y - 3*s, 3.5*s, 0, Math.PI*2);
ctx.fill();
}
// Brücke/Aufbau hinten
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(x + 20*s, y - 10*s, 10*s, 10*s);
ctx.fillStyle = '#4a5a6a';
ctx.fillRect(x + 22*s, y - 8*s, 6*s, 2*s);
// Schornstein
ctx.fillStyle = '#3a4250';
ctx.fillRect(x + 24*s, y - 14*s, 3*s, 4*s);
ctx.restore();
}
/** Klassisches Segelschiff: brauner Rumpf, zwei dreieckige Segel am Mast. */
function drawSailShip(x, y, scale, facingLeft) {
ctx.save();
if (facingLeft) { ctx.translate(x, y); ctx.scale(-1, 1); ctx.translate(-x, -y); }
const s = scale;
// Rumpf
ctx.fillStyle = '#7a4a2a';
ctx.beginPath();
ctx.moveTo(x - 10*s, y);
ctx.lineTo(x + 10*s, y);
ctx.quadraticCurveTo(x + 8*s, y + 4*s, x + 5*s, y + 4*s);
ctx.lineTo(x - 5*s, y + 4*s);
ctx.quadraticCurveTo(x - 8*s, y + 4*s, x - 10*s, y);
ctx.closePath();
ctx.fill();
// Mast
ctx.strokeStyle = '#4a3020';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(x + 1*s, y);
ctx.lineTo(x + 1*s, y - 16*s);
ctx.stroke();
// Großsegel (hinten, groß)
ctx.fillStyle = '#f6f0e4';
ctx.beginPath();
ctx.moveTo(x + 1*s, y - 15*s);
ctx.lineTo(x - 7*s, y - 1*s);
ctx.lineTo(x + 1*s, y - 1*s);
ctx.closePath();
ctx.fill();
// Fock (vorne, klein)
ctx.fillStyle = '#ede5d2';
ctx.beginPath();
ctx.moveTo(x + 1*s, y - 15*s);
ctx.lineTo(x + 7*s, y - 2*s);
ctx.lineTo(x + 1*s, y - 2*s);
ctx.closePath();
ctx.fill();
ctx.restore();
}
/* ============================================================
HUBSCHRAUBER fliegt horizontal, solange der Flughafen existiert.
Er verkehrt zwischen Flughafen und dem Gipfel-Bereich.
============================================================ */
const HELI = { x: 20, y: 28, dir: 1, speed: 0.12, bobPhase: 0 };
function updateHeli() {
const m = _animMult(); if (m === 0) return;
HELI.x += HELI.dir * HELI.speed * m;
if (HELI.x > 88) { HELI.dir = -1; HELI.x = 88; }
if (HELI.x < 12) { HELI.dir = 1; HELI.x = 12; }
HELI.bobPhase += 0.12 * m;
}
/** Murenschutz / Schlammlawinen-Schutzwall am unteren Vulkanhang.
* Wird nur gezeichnet, wenn state.hasMountainShield gesetzt ist (vom
* Bürger-Event Anna vom Bergdorf" aktiviert). Liegt quer zum Hang,
* etwas unter der Vulkan-Basis. */
/** Künstliche Sandaufschüttung (aus Maria-Event): dünne beige Sand-Linie
* knapp ÜBER der aktuellen Wasserlinie, quer über die ganze Insel-Breite.
* Bewegt sich mit dem steigenden Meer mit erinnert didaktisch daran,
* dass eine Aufschüttung gegen Meeresanstieg nur temporär schützt. */
function drawArtificialSandBeach(W, H, waterY) {
ctx.save();
// Hauptkörper: leicht gewellte Linie
const beachY = waterY - 4;
const wave = 1.2;
ctx.fillStyle = '#f1e0ae';
ctx.beginPath();
ctx.moveTo(0, beachY);
for (let x = 0; x <= W; x += 16) {
ctx.lineTo(x, beachY + Math.sin(x * 0.06) * wave);
}
ctx.lineTo(W, beachY + 6);
ctx.lineTo(0, beachY + 6);
ctx.closePath();
ctx.fill();
// Helle Oberkante
ctx.strokeStyle = '#fff1c8';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(0, beachY);
for (let x = 0; x <= W; x += 16) {
ctx.lineTo(x, beachY + Math.sin(x * 0.06) * wave);
}
ctx.stroke();
// Kleine Sand-Körnchen-Punkte (dezente Textur)
ctx.fillStyle = 'rgba(165, 130, 70, 0.3)';
for (let i = 0; i < Math.floor(W / 30); i++) {
const x = (i * 31 + 7) % W;
ctx.beginPath();
ctx.arc(x, beachY + 2, 0.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawMountainShield(W, H, pxX, pxY) {
// Direkt an der Grenze zwischen Vulkan und Insel leicht oberhalb des
// Vulkanfusses, am unteren Rand der Vulkanflanken.
const y = pxY(ISLAND.yVolcanoBase - 2);
// Zwei Wälle, einer links, einer rechts, flankieren den Vulkanfuß direkt
// an der Flanken-Unterkante (Welt-x 28 und 72).
for (const side of [-1, 1]) {
const cx = pxX(50 + side * 22);
ctx.save();
ctx.fillStyle = '#7a6a58';
ctx.beginPath();
ctx.moveTo(cx - 18, y + 6);
ctx.lineTo(cx - 12, y - 4);
ctx.lineTo(cx + 12, y - 4);
ctx.lineTo(cx + 18, y + 6);
ctx.closePath();
ctx.fill();
// Stein-Muster (senkrechte Linien)
ctx.strokeStyle = 'rgba(45,40,35,.35)';
ctx.lineWidth = 0.8;
for (let i = -10; i <= 10; i += 4) {
ctx.beginPath();
ctx.moveTo(cx + i, y - 3);
ctx.lineTo(cx + i, y + 5);
ctx.stroke();
}
// Oberkante (Betonkrone)
ctx.fillStyle = '#9a8a78';
ctx.fillRect(cx - 13, y - 5, 26, 2);
ctx.restore();
}
}
function drawHelicopter(x, y, facingLeft) {
ctx.save();
if (facingLeft) { ctx.translate(x, y); ctx.scale(-1, 1); ctx.translate(-x, -y); }
// Rumpf
ctx.fillStyle = '#c74a3a';
ctx.beginPath();
ctx.ellipse(x, y, 11, 4.5, 0, 0, Math.PI*2);
ctx.fill();
// Cockpit (transparent-blau)
ctx.fillStyle = '#6aadc8';
ctx.beginPath();
ctx.ellipse(x + 7, y - 0.5, 4, 3, 0, 0, Math.PI*2);
ctx.fill();
// Heck
ctx.fillStyle = '#a53a2a';
ctx.beginPath();
ctx.moveTo(x - 10, y - 1);
ctx.lineTo(x - 18, y - 0.5);
ctx.lineTo(x - 18, y + 0.5);
ctx.lineTo(x - 10, y + 1);
ctx.closePath();
ctx.fill();
// Heck-Flosse
ctx.fillStyle = '#c74a3a';
ctx.beginPath();
ctx.moveTo(x - 18, y - 1);
ctx.lineTo(x - 21, y - 4);
ctx.lineTo(x - 16, y - 1);
ctx.closePath();
ctx.fill();
// Landekufen
ctx.strokeStyle = '#4a4a4a';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x - 8, y + 5.5); ctx.lineTo(x + 8, y + 5.5);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - 4, y + 4.5); ctx.lineTo(x - 4, y + 5.5);
ctx.moveTo(x + 4, y + 4.5); ctx.lineTo(x + 4, y + 5.5);
ctx.stroke();
// Hauptrotor schnelle Rotation als Strich, der sich orientiert
const rotorAngle = state.animMs / 30;
const rLen = 18;
const blur = Math.sin(rotorAngle) * rLen;
ctx.strokeStyle = 'rgba(60,60,60,0.55)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x - rLen, y - 6);
ctx.lineTo(x + rLen, y - 6);
ctx.stroke();
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - blur, y - 6);
ctx.lineTo(x + blur, y - 6);
ctx.stroke();
// Mast
ctx.strokeStyle = '#3a3a3a';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, y - 3); ctx.lineTo(x, y - 6); ctx.stroke();
ctx.restore();
}
/* ============================================================
FARB-HELFER: Himmel wird bei steigender Temperatur rötlicher
============================================================ */
function skyColor(y) {
// y=0 (oben) blasser blau, y=1 heller
const t = state.currentTemp;
const warm = Math.max(0, Math.min(1, (t - 15.5) / 2.5));
const topR = Math.round(154 + (232 - 154) * warm);
const topG = Math.round(200 + (175 - 200) * warm);
const topB = Math.round(220 + (155 - 220) * warm);
const botR = Math.round(207 + (240 - 207) * warm);
const botG = Math.round(227 + (200 - 227) * warm);
const botB = Math.round(237 + (195 - 237) * warm);
return y === 'top'
? 'rgb('+topR+','+topG+','+topB+')'
: 'rgb('+botR+','+botG+','+botB+')';
}
/* ============================================================
HAUPT-ZEICHENFUNKTION
============================================================ */
function drawCanvas() {
const W = canvas.clientWidth, H = canvas.clientHeight;
if (!W || !H) return;
// Hart clearen, damit keine Alpha-Akkumulation (z. B. vom Wolken-Layer
// oder Water-Layer) zwischen aufeinanderfolgenden Frames stattfindet.
ctx.clearRect(0, 0, W, H);
// Welt-Y zu Pixel-Y: einfache 1:1 Skalierung
const pxY = yw => (yw / 100) * H;
const pxX = xw => (xw / 100) * W;
// Wasserlinie in Pixeln: Meeresspiegel verschiebt sie nach oben
const seaRiseWorld = Math.min(8, state.seaLevelCm * 0.10); // Welt-Einheiten
const waterY = pxY(ISLAND.ySeaBaseline - seaRiseWorld);
// --- 1. Himmel (Gradient) ---
const sky = ctx.createLinearGradient(0, 0, 0, pxY(55));
sky.addColorStop(0, skyColor('top'));
sky.addColorStop(1, skyColor('bot'));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, pxY(55));
// --- 2. Sonne ---
ctx.beginPath();
ctx.arc(pxX(85), pxY(16), Math.min(26, W*0.035), 0, Math.PI*2);
ctx.fillStyle = '#f4d36a';
ctx.fill();
// --- 3. Wolken (driftend) ---
updateClouds();
for (const c of CLOUDS) drawCloud(pxX(c.x), pxY(c.y), 22 * c.r);
// --- 3b. Feiner Horizont-Farbstreifen (dort, wo in der Ferne Meer und
// Himmel aneinanderstoßen) — dezenter blauer Saum neben dem Vulkan,
// der die Illusion eines fernen Ozeans liefert.
const horizY = pxY(55.5);
const hg = ctx.createLinearGradient(0, horizY - 4, 0, horizY + 2);
hg.addColorStop(0, 'rgba(140, 170, 195, 0)');
hg.addColorStop(1, 'rgba(140, 170, 195, 0.45)');
ctx.fillStyle = hg;
ctx.fillRect(0, horizY - 4, W, 6);
// --- 3c. Fernschiffe auf dem fernen Horizont (klein + langsam). Werden
// von Vulkan und Hügeln später partiell überdeckt → sie „verschwinden
// hinter der Insel", wenn sie die Mitte passieren.
updateFarShips();
drawTanker (pxX(TANKER.x), pxY(TANKER.y), TANKER.scale, TANKER.dir < 0);
drawSailShip(pxX(SAILR.x), pxY(SAILR.y), SAILR.scale, SAILR.dir < 0);
// --- 4. Vulkan (ganz hinten, zwischen Himmel und ersten Hügeln) ---
drawVolcano(W, H, pxX, pxY);
// (Mountain-Shield wird jetzt über drawObjectsYSorted gerendert, damit er
// nicht von der hintersten Hügelreihe überdeckt wird.)
// --- 4b. Hubschrauber verkehrt vor dem Vulkan, wenn Flughafen existiert ---
const airport = state.ownedMeasures['airport'];
if (airport && airport.count > 0) {
updateHeli();
const bob = Math.sin(HELI.bobPhase) * 0.6;
drawHelicopter(pxX(HELI.x), pxY(HELI.y + bob), HELI.dir < 0);
}
// --- 5. Hügel-Reihen (hinten vorne), Flat-Illustration-Stil ---
for (const layer of HILL_LAYERS) drawHillLayer(layer, pxX, pxY, W, H);
// --- 6. Strand-Kuppeln (hellbraun/beige), werden vom Wasser überflutet ---
for (const layer of BEACH_LAYERS) drawHillLayer(layer, pxX, pxY, W, H);
// --- 6b. Künstliche Sandaufschüttung (aus Maria-Event) dünne Sandlinie
// direkt über der aktuellen Wasserlinie, wandert mit dem Meer mit.
if (state.hasSandBeach) drawArtificialSandBeach(W, H, waterY);
// --- 7. Objekte (Häuser + Maßnahmen) y-sortiert ---
// Alle Objekte werden nach ihrer Basis-y sortiert und in dieser
// Reihenfolge gezeichnet Dinge mit höherer Basis (weiter vorne)
// überdecken Dinge mit niedrigerer Basis (weiter hinten).
// Häuser werden so automatisch vor Solar/Radweg gezeichnet, wenn sie
// auf demselben Hügel weiter vorne stehen.
drawObjectsYSorted(W, H, pxX, pxY, waterY);
// --- 9. Wasser (mit animierten Wellen) ---
drawWater(W, H, waterY);
// --- 10. Wolken-Impfungs-Schiffe vorne auf dem Meer (nach Wasser,
// damit sie nicht versunken wirken). ---
const cs = state.ownedMeasures['cloud-seed'];
if (cs) {
const m = MEASURES.find(x => x.id === 'cloud-seed');
for (const inst of cs.instances) {
drawCloudSeedingShip(pxX(inst.pos.x), waterY - 2);
}
}
// --- 11. Fischerboot + vorderes Segelboot (nah, vor den Wellen) ---
updateBoat();
drawBoat(pxX(BOAT.x), waterY - 2);
updateFrontSail();
drawSailShip(pxX(SAILB.x), waterY - 2, SAILB.scale, SAILB.dir < 0);
// --- 11. CO-Smog-Layer bei hohem CO ---
if (state.co2Ppm > 500) {
const smog = Math.min(0.35, (state.co2Ppm - 500) / 600);
ctx.fillStyle = 'rgba(120,90,70,'+smog+')';
ctx.fillRect(0, 0, W, pxY(55));
}
// --- 11. HUD-Label unten links ---
ctx.save();
ctx.fillStyle = 'rgba(31,78,90,0.55)';
ctx.font = '600 11px Inter, sans-serif';
ctx.fillText('Insel-Modell · 1 Jahr = 1 Runde', 12, H - 8);
ctx.restore();
}
function drawCloud(cx, cy, r) {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath();
ctx.arc(cx, cy, r*0.6, 0, Math.PI*2);
ctx.arc(cx + r*0.5, cy - r*0.2, r*0.5, 0, Math.PI*2);
ctx.arc(cx + r*1.0, cy, r*0.6, 0, Math.PI*2);
ctx.arc(cx + r*0.4, cy + r*0.15, r*0.55, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
/** Vulkan: ferner Berg im Hintergrund — steht hinter den Hügeln, Basis
* endet an der hintersten Hügelreihe. Breite ähnlich dieser Reihe.
* Luftperspektive: heller und bläulicher als Nahtöne, damit er visuell
* „weit weg" wirkt. Gletscher-Haube deckt die Kuppe großzügig ab. */
function drawVolcano(W, H, pxX, pxY) {
const baseY = pxY(ISLAND.yVolcanoBase);
const peakY = pxY(ISLAND.yVolcanoPeak);
const cx = pxX(50);
const baseHalf = (26 / 100) * W; // Basis: so breit wie hinterste Hügelreihe
const topHalf = (10 / 100) * W; // Kuppe schmal
const capBulge = (4.5 / 100) * H;
ctx.save();
// Luftperspektive NUR über hellere/kältere Farbtöne, KEIN globalAlpha —
// sonst scheinen die Fernschiffe dahinter durch.
const g = ctx.createLinearGradient(cx, peakY - capBulge, cx, baseY);
g.addColorStop(0, '#b3bcc6'); // heller Top (Luftperspektive)
g.addColorStop(1, '#8c96a2'); // mittleres Kaltgrau
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(cx - baseHalf, baseY);
ctx.lineTo(cx - topHalf, peakY);
ctx.bezierCurveTo(
cx - topHalf * 0.35, peakY - capBulge,
cx + topHalf * 0.35, peakY - capBulge,
cx + topHalf, peakY
);
ctx.lineTo(cx + baseHalf, baseY);
ctx.closePath();
ctx.fill();
// Sanfte Schattenlinien an den Flanken (Tiefenwirkung)
ctx.strokeStyle = 'rgba(50,55,60,.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - baseHalf*0.35, baseY);
ctx.lineTo(cx - topHalf *0.35, peakY + 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx + baseHalf*0.35, baseY);
ctx.lineTo(cx + topHalf *0.35, peakY + 2);
ctx.stroke();
// Gletscher-Haube: deckt die Kuppe großzügig ab — etwa 20 % breiter und
// höher als die Kuppe selbst, damit der Vulkan OBEN wirklich unter einer
// Eiskappe verschwindet. Schrumpft mit steigender Temperatur.
const glacierRemaining = Math.max(0, 1 - (state.currentTemp - 15.2) / 2.8);
if (glacierRemaining > 0.04) {
const gHalf = topHalf * (1.22 * (0.60 + 0.40 * glacierRemaining));
const gBulge = capBulge * (1.22 * (0.80 + 0.40 * glacierRemaining));
// Unterkante des Gletschers liegt leicht UNTERHALB der Kuppe, damit
// die Haube wirklich über die Bergkante überlappt.
const glacierBaseY = peakY + 4;
const gg = ctx.createLinearGradient(cx, peakY - gBulge, cx, glacierBaseY);
gg.addColorStop(0, '#ffffff');
gg.addColorStop(1, '#d8e3ec');
ctx.fillStyle = gg;
ctx.beginPath();
ctx.moveTo(cx - gHalf, glacierBaseY);
ctx.bezierCurveTo(
cx - gHalf * 0.35, peakY - gBulge,
cx + gHalf * 0.35, peakY - gBulge,
cx + gHalf, glacierBaseY
);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// Feste Häuser-Positionen (Welt-Koords) verteilt über die Ei-Silhouette.
// Wird von drawObjectsYSorted zusammen mit den gebauten Maßnahmen y-sortiert.
const HOUSES = [
{ xw: 42, yw: 66 }, { xw: 58, yw: 67 }, // hinten
{ xw: 30, yw: 71 }, { xw: 50, yw: 70 }, { xw: 70, yw: 71 }, // zweite Reihe
{ xw: 22, yw: 75 }, { xw: 40, yw: 74 }, { xw: 62, yw: 75 }, { xw: 78, yw: 75 }, // Mitte breit
{ xw: 28, yw: 79 }, { xw: 50, yw: 80 }, { xw: 72, yw: 79 }, // vordere Hänge
{ xw: 20, yw: 83 }, { xw: 80, yw: 83 }, // Dorf-Ränder
];
/**
* Zeichnet Häuser UND gebaute Maßnahmen in einheitlicher y-sortierter
* Reihenfolge. Objekte mit höherer Basis-y (weiter vorne) werden später
* gezeichnet und liegen damit visuell im Vordergrund.
*
* Sonderfall Mangrove: ihre y-Position folgt der AKTUELLEN Wasserlinie
* (steigt mit dem Meer), sodass sie nicht unter Wasser gepflanzt wird.
*/
function drawObjectsYSorted(W, H, pxX, pxY, waterY) {
const items = [];
// Häuser
// Dynamisch: nur so viele Häuser zeichnen, wie Bevölkerung es rechtfertigt
// (ein Haus pro ~500 Einwohner, min 1, max HOUSES.length).
const visible = visibleHouseCount();
// Pro-Haus-Erneuerbaren-Anteil: Coverage = renewablePower / Bedarf. Die
// ersten `greenHouseCount` Häuser bekommen KEINEN Schornstein mehr (sie
// beheizen sich über Solar/Wind), der Rest hat Schornstein + Rauch.
const _hhDemand = Math.max(0, Math.round((state.population || 0) / 1000));
const _hhGreen = state.renewablePower || 0;
const _hhCoverage = _hhDemand > 0 ? Math.min(1, _hhGreen / _hhDemand) : 1;
const greenHouseCount = Math.round(visible * _hhCoverage);
for (let i = 0; i < visible; i++) {
const h = HOUSES[i];
const px = pxX(h.xw);
const py = pxY(h.yw);
const hasChimney = i >= greenHouseCount; // erste N Häuser sind „grün"
items.push({
baseY: h.yw,
draw: () => drawHouse(px, py, py > waterY - 3, false, hasChimney),
});
}
// Maßnahmen — außer Cloud-Seed (fährt vorne auf dem Wasser → eigene Phase
// nach dem Wasser-Layer).
const seaRise = Math.min(8, state.seaLevelCm * 0.10);
const waterLineWorldY = ISLAND.ySeaBaseline - seaRise;
for (const id in state.ownedMeasures) {
if (id === 'cloud-seed') continue; // wird später, über dem Wasser, gezeichnet
const entry = state.ownedMeasures[id];
const m = MEASURES.find(x => x.id === id);
if (!m) continue;
for (const inst of entry.instances) {
let worldY = inst.pos.y;
// Küstenschutz + Mangroven wandern mit der Wasserlinie mit — sonst
// landen sie bei steigendem Meer unter Wasser.
if (id === 'mangrove') worldY = waterLineWorldY - 0.5;
if (id === 'dike') worldY = waterLineWorldY - 0.5;
if (id === 'sea-wall') worldY = waterLineWorldY - 1.5;
if (id === 'sand-fill') worldY = waterLineWorldY - 0.2; // direkt an der Wasserkante
const px = pxX(inst.pos.x);
const py = pxY(worldY);
items.push({
baseY: worldY,
draw: () => drawMeasure(id, m, px, py, inst),
});
}
}
// Mountain-Shield — sitzt an der Grenze zwischen Vulkan und Insel. Basis-Y
// knapp über der hintersten Hügelreihe, damit er davor erscheint und nicht
// überdeckt wird.
if (state.hasMountainShield) {
items.push({
baseY: ISLAND.yVolcanoBase - 1.5,
draw: () => drawMountainShield(W, H, pxX, pxY),
});
}
items.sort((a, b) => a.baseY - b.baseY);
for (const it of items) it.draw();
}
/**
* drawHouse(x, y, flooded, greenRoof, hasChimney=true)
* hasChimney=false → Haus hat KEINEN Schornstein (weil durch erneuerbare
* Energie beheizt). greenRoof-Häuser haben implizit keinen Schornstein.
*/
function drawHouse(x, y, flooded, greenRoof, hasChimney) {
if (hasChimney == null) hasChimney = true;
if (greenRoof) hasChimney = false;
ctx.save();
const bodyColor = flooded ? '#8b7f72' : '#f3efe5';
const roofColor = flooded ? '#5c5348' : (greenRoof ? '#6a9b56' : '#c85c4a');
// Körper
ctx.fillStyle = bodyColor;
ctx.fillRect(x-10, y-12, 20, 14);
ctx.strokeStyle = 'rgba(0,0,0,.15)';
ctx.strokeRect(x-10, y-12, 20, 14);
// Dach
ctx.fillStyle = roofColor;
ctx.beginPath();
ctx.moveTo(x-12, y-12);
ctx.lineTo(x, y-22);
ctx.lineTo(x+12, y-12);
ctx.closePath();
ctx.fill();
if (greenRoof) {
// Gras-Textur auf dem Dach
ctx.strokeStyle = '#4a7a3a';
ctx.lineWidth = 1;
for (let i = -8; i <= 8; i += 3) {
ctx.beginPath();
ctx.moveTo(x + i, y - 14 - Math.abs(i)*0.3);
ctx.lineTo(x + i, y - 16 - Math.abs(i)*0.3);
ctx.stroke();
}
}
// Schornstein (rechts auf dem Dach) — nur wenn das Haus fossil beheizt
// wird. Häuser mit erneuerbarer Versorgung haben den Schornstein bereits
// abgebaut.
if (!flooded && hasChimney) {
ctx.fillStyle = '#8a6a5a';
ctx.fillRect(x + 4, y - 20, 3, 6);
ctx.fillStyle = '#5a3a2a';
ctx.fillRect(x + 4, y - 20, 3, 1);
}
// Tür
ctx.fillStyle = flooded ? '#3d342b' : '#4a7c8a';
ctx.fillRect(x-2, y-7, 4, 6);
// Rauchfahne — drei aufsteigende Puffs, solange der Schornstein steht
// (sobald das Haus auf erneuerbare Energie umgestellt ist, fällt sowohl
// Schornstein als auch Rauch weg).
if (!flooded && hasChimney) {
var t = state.animMs * 0.001;
var seed = (x * 0.73) % 10;
var sx = x + 5.5; // Schornstein-Öffnung
var sy = y - 20;
for (var k = 0; k < 3; k++) {
var phase = (t * 0.8 + seed + k * 0.7) % 1; // 0..1 rising
var r = 2 + phase * 4; // wächst beim Aufsteigen
var dy = -phase * 18; // steigt nach oben
var dx = Math.sin((t + seed + k) * 1.3) * 2.5 * phase;
var alpha = (1 - phase) * 0.55;
ctx.fillStyle = 'rgba(90,90,95,' + alpha.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(sx + dx, sy + dy, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
/* ============================================================
EINZELNE MAßNAHMEN-GRAFIKEN (Canvas-Zeichnung, keine Emojis)
============================================================ */
function drawMeasure(id, m, x, y, inst) {
switch (id) {
case 'forest': return drawForestTree(x, y);
case 'mangrove': return drawMangrove(x, y);
case 'solar': return drawSolarPanel(x, y);
case 'wind': return drawWindTurbine(x, y);
case 'dike': return drawDike(x, y);
case 'sea-wall': return drawSeaWall(x, y);
case 'sand-fill': return drawSandFill(x, y, inst);
case 'green-roof': return drawHouse(x, y, false, true);
case 'bikes': return drawBikePath(x, y);
case 'coal': return drawCoalPlant(x, y);
case 'airport': return drawAirport(x, y);
case 'cloud-seed': return drawCloudSeedingShip(x, y);
default: {
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = '20px sans-serif'; ctx.fillText(m.emoji, x, y);
ctx.restore();
}
}
}
/** Nadelbaum-Silhouette: brauner Stamm + grüner Dreieck-Stapel */
function drawForestTree(x, y) {
ctx.save();
ctx.fillStyle = '#6a4a2a';
ctx.fillRect(x-1.5, y-4, 3, 8);
ctx.fillStyle = '#3e7a3e';
ctx.beginPath();
ctx.moveTo(x, y-18);
ctx.lineTo(x-8, y-4);
ctx.lineTo(x+8, y-4);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#4a8a4e';
ctx.beginPath();
ctx.moveTo(x, y-13);
ctx.lineTo(x-6, y-5);
ctx.lineTo(x+6, y-5);
ctx.closePath(); ctx.fill();
ctx.restore();
}
/** Mangrove: Büschel aus 3 dünnen Stämmen mit grüner Krone, direkt an Wasserlinie */
function drawMangrove(x, y) {
ctx.save();
// Stützwurzeln (dünne braune Streifen schräg nach unten)
ctx.strokeStyle = '#5a4030';
ctx.lineWidth = 1.2;
for (let i = -4; i <= 4; i += 2) {
ctx.beginPath(); ctx.moveTo(x+i, y); ctx.lineTo(x+i*1.5, y+4); ctx.stroke();
}
// 3 Stämme
ctx.fillStyle = '#6a4a30';
for (const dx of [-3, 0, 3]) ctx.fillRect(x+dx-0.8, y-6, 1.6, 6);
// Kronen
ctx.fillStyle = '#3a6e3a';
for (const dx of [-3, 0, 3]) {
ctx.beginPath(); ctx.arc(x+dx, y-8, 4, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle = '#4e8c4e';
for (const dx of [-3, 0, 3]) {
ctx.beginPath(); ctx.arc(x+dx, y-10, 2.5, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
}
/** Solar: dunkles Parallelogramm mit Gitter, auf Ständer */
function drawSolarPanel(x, y) {
ctx.save();
// Ständer
ctx.strokeStyle = '#5a5a5a'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(x-5, y); ctx.lineTo(x-2, y-8); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x+7, y); ctx.lineTo(x+4, y-8); ctx.stroke();
// Panel (Parallelogramm)
ctx.fillStyle = '#1a3a6a';
ctx.beginPath();
ctx.moveTo(x-9, y-6);
ctx.lineTo(x+10, y-10);
ctx.lineTo(x+8, y-3);
ctx.lineTo(x-11, y+1);
ctx.closePath(); ctx.fill();
// Gitter
ctx.strokeStyle = '#4a6a9a'; ctx.lineWidth = 0.6;
for (let i = 1; i <= 3; i++) {
ctx.beginPath();
ctx.moveTo(x - 9 + (19*i/4), y - 6 + (4*i/4));
ctx.lineTo(x - 11 + (19*i/4), y + 1 + (4*i/4) - 4);
ctx.stroke();
}
ctx.beginPath(); ctx.moveTo(x-10, y-4.5); ctx.lineTo(x+9, y-6.5); ctx.stroke();
ctx.restore();
}
/** Windrad: weißer Mast + 3 rotierende Blätter (animiert über Tick-Phase) */
function drawWindTurbine(x, y) {
ctx.save();
// Mast
ctx.fillStyle = '#eaeaea';
ctx.fillRect(x-1, y-24, 2, 24);
ctx.fillStyle = '#bdbdbd';
ctx.beginPath(); ctx.arc(x, y-24, 2.2, 0, Math.PI*2); ctx.fill();
// 3 Rotorblätter (rotieren mit der Zeit, proportional zur Simulation)
const rot = (state.animMs / 800) % (Math.PI*2);
ctx.translate(x, y-24);
for (let i = 0; i < 3; i++) {
ctx.save();
ctx.rotate(rot + i * (Math.PI*2/3));
ctx.fillStyle = '#f5f5f5';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(1.2, -9);
ctx.lineTo(0, -11);
ctx.lineTo(-1.2, -9);
ctx.closePath(); ctx.fill();
ctx.restore();
}
ctx.restore();
}
/** Deich: trapezförmige Aufschüttung (braun-grau), quer zur Küste */
function drawDike(x, y) {
ctx.save();
ctx.fillStyle = '#8a7560';
ctx.beginPath();
ctx.moveTo(x-12, y+2);
ctx.lineTo(x-8, y-6);
ctx.lineTo(x+8, y-6);
ctx.lineTo(x+12, y+2);
ctx.closePath(); ctx.fill();
// Gras oben
ctx.strokeStyle = '#4a7a3a'; ctx.lineWidth = 1;
for (let i = -7; i <= 7; i += 2) {
ctx.beginPath(); ctx.moveTo(x+i, y-6); ctx.lineTo(x+i, y-8); ctx.stroke();
}
ctx.restore();
}
/** Hochwasserschutz: hohes, schmaleres Trapez aus dunklem Stein */
function drawSeaWall(x, y) {
ctx.save();
ctx.fillStyle = '#5a5a60';
ctx.beginPath();
ctx.moveTo(x-10, y+2);
ctx.lineTo(x-8, y-14);
ctx.lineTo(x+8, y-14);
ctx.lineTo(x+10, y+2);
ctx.closePath(); ctx.fill();
// Stein-Linien
ctx.strokeStyle = '#3a3a40'; ctx.lineWidth = 0.6;
for (let i = -12; i < 0; i += 3) {
ctx.beginPath(); ctx.moveTo(x-8, y+i); ctx.lineTo(x+8, y+i); ctx.stroke();
}
ctx.restore();
}
/** Sand-Aufschüttung: hellbraune Halbellipse am Strand. Erodiert je Jahr. */
function drawSandFill(x, y, inst) {
ctx.save();
const age = Math.max(0, state.tick - (inst.builtAt || 0));
const remaining = Math.max(0, 1 - age / 9); // nach 9 Jahren weg
if (remaining <= 0) { ctx.restore(); return; }
ctx.fillStyle = '#e9dcb0';
ctx.beginPath();
ctx.ellipse(x, y, 14 * remaining, 5 * remaining, 0, Math.PI, 0);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.08)'; ctx.stroke();
ctx.restore();
}
/** Radweg: großer, gut sichtbarer Streifen mit gestrichelter Mittellinie
* und einem kleinen Radfahrer-Symbol zur Verdeutlichung. */
function drawBikePath(x, y) {
ctx.save();
// Asphalt-Band
ctx.fillStyle = '#8b8a82';
ctx.beginPath();
ctx.ellipse(x, y, 30, 7, 0, 0, Math.PI*2);
ctx.fill();
// Heller Schulterstreifen
ctx.fillStyle = '#b0afa6';
ctx.beginPath();
ctx.ellipse(x, y, 30, 6.5, 0, 0, Math.PI*2);
ctx.fill();
// Weiße gestrichelte Mittellinie
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(x - 26, y); ctx.lineTo(x + 26, y); ctx.stroke();
ctx.setLineDash([]);
// Kleines Rad-Symbol (zwei Kreise + Rahmen)
const bx = x - 6, by = y - 3;
ctx.strokeStyle = '#2e6e9a';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(bx, by, 2, 0, Math.PI*2); ctx.stroke();
ctx.beginPath(); ctx.arc(bx + 6, by, 2, 0, Math.PI*2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + 3, by - 3);
ctx.lineTo(bx + 6, by);
ctx.stroke();
ctx.restore();
}
/** Kohlekraftwerk: grauer Quader + Schornstein mit Rauchfahne */
function drawCoalPlant(x, y) {
ctx.save();
ctx.fillStyle = '#6a6a6a';
ctx.fillRect(x-10, y-14, 20, 14);
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(x+3, y-22, 4, 8);
// Rauch
ctx.fillStyle = 'rgba(80,70,60,0.55)';
const t = state.animMs / 300;
ctx.beginPath();
ctx.arc(x+5 + Math.sin(t)*1.5, y-26, 3, 0, Math.PI*2); ctx.fill();
ctx.beginPath();
ctx.arc(x+7 + Math.sin(t+1)*1.5, y-32, 4, 0, Math.PI*2); ctx.fill();
// Fenster
ctx.fillStyle = '#c9a85a';
for (let i = -7; i <= 5; i += 4)
ctx.fillRect(x+i, y-10, 2, 2);
ctx.restore();
}
/** Flughafen: große Landebahn mit Terminal und parkendem Flugzeug.
* 4× größer als vorher, sitzt laut Zone weit hinten auf den Hügeln. */
function drawAirport(x, y) {
ctx.save();
// Landebahn (grau mit Schulter)
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(x - 70, y - 5, 140, 10);
ctx.fillStyle = '#6a6a6a';
ctx.fillRect(x - 70, y - 6, 140, 1);
ctx.fillRect(x - 70, y + 5, 140, 1);
// Mittelstreifen (weiß gestrichelt)
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.setLineDash([8, 6]);
ctx.beginPath();
ctx.moveTo(x - 65, y);
ctx.lineTo(x + 65, y);
ctx.stroke();
ctx.setLineDash([]);
// Schwelle-Markierungen an beiden Enden
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 4; i++) {
ctx.fillRect(x - 68 + i*2, y - 3, 1, 6);
ctx.fillRect(x + 66 - i*2, y - 3, 1, 6);
}
// Terminal-Gebäude (oberhalb der Landebahn)
ctx.fillStyle = '#d0c8b6';
ctx.fillRect(x - 26, y - 20, 52, 14);
// Dach
ctx.fillStyle = '#7a8a94';
ctx.fillRect(x - 28, y - 22, 56, 3);
// Tower
ctx.fillStyle = '#e0d8c6';
ctx.fillRect(x + 14, y - 32, 7, 14);
ctx.fillStyle = '#4a6a78';
ctx.fillRect(x + 13, y - 34, 9, 3);
// Terminal-Fenster
ctx.fillStyle = '#5a7a8a';
for (let i = 0; i < 6; i++) ctx.fillRect(x - 22 + i*8, y - 16, 4, 4);
// Parkendes Flugzeug am linken Ende der Landebahn
ctx.fillStyle = '#f2f2f2';
// Rumpf
ctx.beginPath();
ctx.ellipse(x - 40, y - 2, 14, 3, 0, 0, Math.PI*2);
ctx.fill();
// Flügel
ctx.beginPath();
ctx.moveTo(x - 44, y - 2);
ctx.lineTo(x - 32, y - 6);
ctx.lineTo(x - 30, y - 2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(x - 44, y - 2);
ctx.lineTo(x - 32, y + 2);
ctx.lineTo(x - 30, y - 2);
ctx.closePath();
ctx.fill();
// Heckflosse
ctx.beginPath();
ctx.moveTo(x - 52, y - 2);
ctx.lineTo(x - 48, y - 7);
ctx.lineTo(x - 46, y - 2);
ctx.closePath();
ctx.fill();
// Cockpit-Fenster
ctx.fillStyle = '#4a6a7a';
ctx.fillRect(x - 32, y - 3.5, 4, 2);
// Helipad immer sichtbar
var hx = x + 48, hy = y + 14;
ctx.fillStyle = '#3a3a3a';
ctx.beginPath(); ctx.arc(hx, hy, 11, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.4;
ctx.beginPath(); ctx.arc(hx, hy, 9, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.fillRect(hx - 4, hy - 4, 1.5, 8);
ctx.fillRect(hx + 2.5, hy - 4, 1.5, 8);
ctx.fillRect(hx - 3, hy - 0.7, 6, 1.4);
// Helikopter: fliegt meist, landet gelegentlich auf dem Pad. Jeder Airport
// hat seinen eigenen Zyklus (Seed aus x). Zyklus 26-34 s:
// 0..0.70 Flugphase (wandernde Bahn am Himmel)
// 0.70..0.80 Anflug (Flugpunkt Pad)
// 0.80..1.00 auf Pad parken (Rotor fast gestoppt)
var t = state.animMs * 0.001;
var seed = (Math.abs(x) * 0.137) % 10;
var cycle = 26 + seed * 0.8;
var p = ((t + seed * 3) % cycle) / cycle;
function flightPos(pf) {
var angle = pf * Math.PI * 2 + seed;
var rx = 55 + Math.sin(angle * 1.3 + seed) * 10;
var ry = 20 + Math.cos(angle * 0.9) * 4;
return {
x: hx + Math.cos(angle) * rx + Math.sin(angle * 2.1) * 6,
y: hy - 50 + Math.sin(angle) * ry + Math.cos(angle * 2.3) * 3,
angle: angle
};
}
var padX = hx, padY = hy - 6;
var hxPos, hyPos, rotorAlpha = 1, facing = 1;
if (p < 0.08) {
// Start: vom Pad hochheben zum Anfang der Flugbahn
var up = p / 0.08;
var fp0 = flightPos(0);
hxPos = padX + (fp0.x - padX) * up;
hyPos = padY + (fp0.y - padY) * up;
facing = (fp0.x - padX) >= 0 ? 1 : -1;
rotorAlpha = 0.4 + up * 0.6;
} else if (p < 0.72) {
// Flugphase Wander-Bahn
var pf = (p - 0.08) / 0.64;
var fp = flightPos(pf);
hxPos = fp.x; hyPos = fp.y;
var fpNext = flightPos(pf + 0.01);
facing = (fpNext.x - fp.x) >= 0 ? 1 : -1;
} else if (p < 0.80) {
// Anflug: letzter Flugpunkt Pad
var ap = (p - 0.72) / 0.08;
var fpE = flightPos(1);
hxPos = fpE.x + (padX - fpE.x) * ap;
hyPos = fpE.y + (padY - fpE.y) * ap;
facing = (padX - fpE.x) >= 0 ? 1 : -1;
rotorAlpha = 1 - ap * 0.6;
} else {
// Parken auf dem Pad
hxPos = padX; hyPos = padY;
rotorAlpha = 0.2;
facing = 1;
}
// Leichtes Schweben nur während Flug & Anflug
var sway = (p < 0.80) ? Math.sin(t * 3 + seed) * 0.6 : 0;
hyPos += sway;
var rot = (t * 25 + seed) % (Math.PI * 2);
// Helikopter zeichnen facing flippt die Silhouette
ctx.save();
ctx.translate(hxPos, hyPos);
ctx.scale(facing, 1);
// Kufen
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-6, 5); ctx.lineTo(6, 5);
ctx.moveTo(-6, 3); ctx.lineTo(6, 3);
ctx.moveTo(-4, 3); ctx.lineTo(-4, 1);
ctx.moveTo(4, 3); ctx.lineTo(4, 1);
ctx.stroke();
// Rumpf
ctx.fillStyle = '#c43838';
ctx.beginPath(); ctx.ellipse(0, 0, 7, 4, 0, 0, Math.PI*2); ctx.fill();
// Cockpitkuppel (immer nach "vorne")
ctx.fillStyle = '#8fb8d8';
ctx.beginPath(); ctx.ellipse(3, -0.5, 3, 2.5, 0, 0, Math.PI*2); ctx.fill();
// Heckausleger
ctx.fillStyle = '#c43838';
ctx.beginPath();
ctx.moveTo(-5, -0.5);
ctx.lineTo(-13, 0.5);
ctx.lineTo(-13, 1.5);
ctx.lineTo(-5, 0.5);
ctx.closePath(); ctx.fill();
// Heckrotor
ctx.fillStyle = '#8a2a2a';
ctx.beginPath(); ctx.arc(-13, 1, 1.5, 0, Math.PI*2); ctx.fill();
// Hauptrotor
ctx.save();
ctx.translate(0, -4);
ctx.rotate(rot);
ctx.globalAlpha = 0.55 * rotorAlpha;
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 1.4;
ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.stroke();
ctx.globalAlpha = 0.35 * rotorAlpha;
ctx.rotate(Math.PI / 3);
ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.stroke();
ctx.restore();
// Rotor-Nabe
ctx.fillStyle = '#2a2a2a';
ctx.beginPath(); ctx.arc(0, -4, 1.2, 0, Math.PI*2); ctx.fill();
ctx.restore();
ctx.restore();
}
/** Fischer-/Motorboot: stabile Silhouette ohne Segel. Rumpf mit Kabine
* und kleinem Fahnenmast. Gleiches Aussehen von beiden Seiten. */
/** Marine-Cloud-Brightening-Schiff: vor der Küste ankerndes Spezialschiff,
* sprüht feinen Salzwassernebel in einer vertikalen Säule nach oben.
* Die Nebelsäule steigt bis in den Wolken-Bereich und ist leicht animiert. */
function drawCloudSeedingShip(x, y) {
ctx.save();
// Nebelsäule (zuerst, unter dem Schiff, damit Schornstein drüber liegt)
const segs = 12;
const t = state.animMs / 500;
for (let i = 0; i < segs; i++) {
const progress = (i / segs);
const ny = y - 6 - progress * 80;
const sway = Math.sin(t + progress * 3) * 3;
const nx = x + sway;
const r = 2.5 + progress * 6;
const alpha = (1 - progress) * 0.55;
ctx.fillStyle = 'rgba(200, 215, 225, ' + alpha + ')';
ctx.beginPath();
ctx.arc(nx, ny, r, 0, Math.PI*2);
ctx.fill();
}
// Rumpf (breit, grau)
ctx.fillStyle = '#7a8894';
ctx.beginPath();
ctx.moveTo(x - 16, y);
ctx.lineTo(x + 16, y);
ctx.quadraticCurveTo(x + 13, y + 5, x + 10, y + 5);
ctx.lineTo(x - 10, y + 5);
ctx.quadraticCurveTo(x - 13, y + 5, x - 16, y);
ctx.closePath();
ctx.fill();
// Wasserlinie
ctx.fillStyle = '#566370';
ctx.fillRect(x - 14, y + 2, 28, 1);
// Aufbau
ctx.fillStyle = '#e0e5ea';
ctx.fillRect(x - 5, y - 6, 10, 6);
// Sprühturm (hoher Mast über der Mitte)
ctx.fillStyle = '#3a4450';
ctx.fillRect(x - 1, y - 18, 2, 12);
// Trichter/Düse oben
ctx.beginPath();
ctx.moveTo(x - 3, y - 20);
ctx.lineTo(x + 3, y - 20);
ctx.lineTo(x + 2, y - 17);
ctx.lineTo(x - 2, y - 17);
ctx.closePath();
ctx.fill();
// Warnlicht (rot blinkend)
const blink = (Math.sin(t * 6) + 1) * 0.5;
ctx.fillStyle = 'rgba(200, 60, 50, ' + (0.4 + blink * 0.6) + ')';
ctx.beginPath();
ctx.arc(x, y - 22, 1.5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
function drawBoat(x, y) {
ctx.save();
// Rumpf (trapezoid mit leichter Rundung)
ctx.fillStyle = '#ba4a3a';
ctx.beginPath();
ctx.moveTo(x - 14, y);
ctx.lineTo(x + 14, y);
ctx.quadraticCurveTo(x + 11, y + 6, x + 8, y + 6);
ctx.lineTo(x - 8, y + 6);
ctx.quadraticCurveTo(x - 11, y + 6, x - 14, y);
ctx.closePath();
ctx.fill();
// Wasserlinie am Rumpf (dunklerer Streifen)
ctx.fillStyle = '#8a2e22';
ctx.fillRect(x - 12, y + 2, 24, 1.2);
// Kabine/Aufbau (cremeweiß mit Fensterband)
ctx.fillStyle = '#f0ece0';
ctx.fillRect(x - 6, y - 6, 12, 6);
ctx.fillStyle = '#3a5a6a';
ctx.fillRect(x - 5, y - 4.5, 10, 2);
// Dach
ctx.fillStyle = '#d0cabc';
ctx.fillRect(x - 7, y - 7, 14, 1.2);
// Kleiner Mast mit Flagge (symbolisch)
ctx.strokeStyle = '#4a4a4a'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(x + 5, y - 7); ctx.lineTo(x + 5, y - 13); ctx.stroke();
ctx.fillStyle = '#c85c4a';
ctx.beginPath();
ctx.moveTo(x + 5, y - 13);
ctx.lineTo(x + 9, y - 11);
ctx.lineTo(x + 5, y - 9);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawWater(W, H, waterY) {
ctx.save();
// Wasserfläche
const grad = ctx.createLinearGradient(0, waterY, 0, H);
grad.addColorStop(0, 'rgba(110,160,180,0.85)');
grad.addColorStop(1, 'rgba(31,78,90,0.98)');
ctx.fillStyle = grad;
const waveAmp = 2 + (state.currentTemp - 15) * 1.0;
const phase = (state.animMs / 1100) % (Math.PI * 2);
ctx.beginPath();
ctx.moveTo(0, waterY);
for (let x = 0; x <= W; x += 10) {
ctx.lineTo(x, waterY + Math.sin(x * 0.025 + phase) * waveAmp);
}
ctx.lineTo(W, H); ctx.lineTo(0, H);
ctx.closePath(); ctx.fill();
// Wellenschaum an der Kante
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= W; x += 10) {
const y = waterY + Math.sin(x * 0.025 + phase) * waveAmp;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
function renderCanvas() {
resizeCanvas();
drawCanvas();
}
window.addEventListener('resize', renderCanvas);
/* ============================================================
TICK LOOP
============================================================ */
// Akkumulierte Spielzeit (Millisekunden), die AUSSCHLIESSLICH läuft, wenn
// das Spiel nicht pausiert ist (Speed > 0, Phase = playing). Sämtliche
// Canvas-Animationen (Wolken, Windräder, Helikopter, Rauch, Wellen) lesen
// diese Zeit statt `performance.now()`, damit Pause alles einfriert und
// Geschwindigkeitsstufen die Bewegung proportional beschleunigen.
let _lastFrameTs = 0;
function frame(now) {
const dtMs = _lastFrameTs ? Math.min(200, now - _lastFrameTs) : 0;
_lastFrameTs = now;
if (state.phase === 'playing' && state.speed > 0) {
state.animMs += dtMs * state.speed;
}
if (state.phase === 'playing') {
if (!state.lastTickMs) state.lastTickMs = now;
const interval = state.MS_PER_TICK / Math.max(0.1, state.speed);
if (state.speed > 0 && now - state.lastTickMs >= interval) {
state.lastTickMs = now;
simulateTick();
renderUI();
}
}
// Canvas Wellen-Animation jederzeit (bei Pause eingefroren, weil animMs steht)
if (state.phase !== 'level-select' && state.phase !== 'loading') {
drawCanvas();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ============================================================
SPEED CONTROL
============================================================ */
$$('.ggs-speed-btn').forEach(btn => {
btn.addEventListener('click', () => {
$$('.ggs-speed-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const sp = parseInt(btn.dataset.speed, 10);
state.speed = sp;
const header = $('#header');
if (sp === 0) { state.phase = state.phase === 'playing' ? 'paused' : state.phase; header.classList.add('paused'); }
else { if (state.phase === 'paused') state.phase = 'playing'; header.classList.remove('paused'); state.lastTickMs = 0; }
});
});
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'p' || e.key === 'P') document.querySelector('[data-speed="0"]').click();
if (e.key === '1') document.querySelector('[data-speed="1"]').click();
if (e.key === '2') document.querySelector('[data-speed="2"]').click();
if (e.key === '3') document.querySelector('[data-speed="4"]').click();
});
/* ============================================================
TUTORIAL
============================================================ */
// Plattform-Convention: -Button im Header (siehe live-client.js GGS_TUTORIAL).
window.GGS_TUTORIAL = { simId: 'klima', title: 'Klimasimulation 2D', replay: function() { startTutorial(); } };
function startTutorial() {
state.tutorialActive = true;
state.tutorialStep = 0;
state.phase = 'tutorial';
renderTutorialStep();
showOverlay('tutorial-overlay');
}
function renderTutorialStep() {
const step = TUTORIAL_STEPS[state.tutorialStep];
$('#tutorial-title').textContent = step.title;
$('#tutorial-text').innerHTML = step.html || step.text || '';
const dots = TUTORIAL_STEPS.map((_, i) => {
const cls = i < state.tutorialStep ? 'done' : i === state.tutorialStep ? 'active' : '';
return '<div class="ggs-tutorial-dot '+cls+'"></div>';
}).join('');
$('#tutorial-dots').innerHTML = dots;
const isLast = state.tutorialStep === TUTORIAL_STEPS.length - 1;
$('#tutorial-next').textContent = isLast ? 'Simulation starten 🚀' : 'Weiter →';
}
function nextTutorialStep() {
if (state.tutorialStep < TUTORIAL_STEPS.length - 1) {
state.tutorialStep++;
renderTutorialStep();
} else {
hideOverlay('tutorial-overlay');
state.tutorialActive = false;
state.phase = 'playing';
state.lastTickMs = 0;
if (window.kwAudio) kwAudio.play('game-start');
autoSave();
}
}
function skipTutorial() {
hideOverlay('tutorial-overlay');
state.tutorialActive = false;
state.phase = 'playing';
if (window.kwAudio) kwAudio.play('game-start');
autoSave();
state.lastTickMs = 0;
}
/* ============================================================
LEVEL START / END
============================================================ */
/**
* Setzt das Spiel vollständig zurück und startet ein neues Level. Kümmert
* sich um:
* - alle Simulations-State-Variablen (Budget, Klima, Timeline, Events, …)
* - abgeleitete Variablen (co2Reduction, protection, upkeepTotal, powerOutput)
* - Meta-Zustände (phase, speed, tutorial, pendingCitizenEvent)
* - UI-Caches (Achievement-Hash, Canvas-Animations-Phase, Glossar-Popup)
* - visuelle Resets (Overlays schließen, Speed-Buttons, Event-Stapel,
* Header-paused"-Klasse)
* Ziel: nichts vom alten Level bleibt bestehen, wenn ein neues startet.
*/
function startGame(levelId) {
// --- 1. Simulations-State: neue Engine-Instanz, view-Flags draufsetzen
const levelConfig = (window.__GGS__ && window.__GGS__.levelConfig) || null;
state = KlimaEngine.createGame(levelId, { levelConfig });
Object.assign(state, {
phase: 'playing',
speed: 1, lastTickMs: 0,
MS_PER_TICK: 8000,
animMs: 0,
tutorialStep: 0, tutorialActive: false,
pendingCitizenEvent: null,
});
// --- 2. UI-Caches ---
_prevAchHash = '';
if (_glossarPopup) { _glossarPopup.remove(); _glossarPopup = null; }
// Neues Level Musik-Override aufheben, damit Auto-Switch (Drama) wieder
// greift, falls der Spieler sich vorher manuell etwas ausgesucht hatte.
if (window.ggsMusicResetManualOverride) window.ggsMusicResetManualOverride();
// --- 3. Visuelle Overlays schließen ---
hideOverlay('level-select-overlay');
hideOverlay('level-overlay');
hideOverlay('end-overlay');
hideOverlay('citizen-overlay');
hideOverlay('info-overlay');
hideOverlay('tutorial-overlay');
// --- 4. Header / Speed-Buttons / Event-Stapel ---
const header = $('#header');
if (header) header.classList.remove('paused');
$$('.ggs-speed-btn').forEach(b => b.classList.remove('active'));
const speed1Btn = document.querySelector('[data-speed="1"]');
if (speed1Btn) speed1Btn.classList.add('active');
const stack = $('#event-stack');
if (stack) stack.classList.remove('open');
// --- 5. Labels + erstes Render ---
$('#level-badge').textContent = 'Level ' + levelId + ' · ' + state.diff.label;
renderUI();
startTutorial();
}
function endLevel(won, reason) {
// Doppelaufruf-Schutz (UI-seitig): sobald wir in 'end'-Phase sind, nichts neu
// machen. Engine hat levelCompleted/levelWon und Achievements bereits gesetzt,
// hier nur UI (Overlay + Sound + Server-Submit).
if (state.phase === 'end') return;
state.phase = 'end';
state.levelCompleted = true;
if (state.levelWon == null) state.levelWon = won && state.tick >= state.maxTick;
// Fallback für View-eigene Aufrufe (z.B. manuelles „Level beenden"): wenn
// die Engine diese Achievements noch nicht gesetzt hat, hier nachholen.
if (state.levelWon) unlockAchievement('survivor');
if (state.budget > 100) unlockAchievement('frugal');
// Sound: Sieg / Niederlage mit Dramatik-Stinger bei Loss
if (window.kwAudio) {
if (state.levelWon) {
kwAudio.play('level-won');
} else {
kwAudio.play('drama-game-over');
setTimeout(() => kwAudio.play('level-lost'), 2500);
// Bei Niederlage dramatischen Hintergrund-Track ziehen (falls nicht
// manuell überschrieben), damit das End-Overlay atmosphärisch passt.
if (window.ggsMusicSetAuto) window.ggsMusicSetAuto('drama');
}
}
const durationMs = Date.now() - state.startTime;
const stars = computeStars();
const score = computeScore();
// Submit assessment
if (window.__GGS__ && window.__GGS__.sessionId) {
fetch((window.__GGS__.baseUrl || '') + '/php/api/progress.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
session_id: window.__GGS__.sessionId,
sim_id: 'klima',
action: 'submit_assessment',
data: {
level: state.difficulty, stars, score,
leistung_pct: Math.max(0, Math.min(100, Math.round(score))),
duration_ms: durationMs, completed: state.levelWon,
results: {
final_co2: Math.round(state.co2Ppm),
final_temperature: +state.currentTemp.toFixed(2),
final_budget: Math.round(state.budget),
final_flooded_pct: Math.round(state.floodedPct),
final_population: Math.round(state.population),
measures_bought: state.actionLog.filter(a=>a.action==='buy').length,
events_survived: state.firedEvents.size,
},
action_log: state.actionLog.slice(0, 200),
badges_earned: Array.from(state.achievements),
}
})
}).catch(()=>{});
}
// Wenn nicht letztes Level & gewonnen: Between-Level-Screen
if (state.levelWon && state.difficulty < 3) {
showBetweenLevel(stars);
} else {
showEndScreen(stars, score, reason);
}
}
function computeStars() {
if (!state.levelWon) {
// Niederlage = 0 oder 1 Stern
return state.tick > 40 ? 1 : 0;
}
let stars = 2; // Basis für Level geschafft
if (state.currentTemp < 17) stars++;
if (state.currentTemp < 16.3) stars++;
if (state.budget > 200) stars++;
if (state.floodedPct < 20 && state.population > state.diff.startPopulation * 0.9) stars = Math.min(5, stars+1);
stars = Math.max(0, Math.min(5, stars));
// Temperatur-Deckel: harte Begrenzung bei Klima-Versagen, damit Sieg-Bonus
// nicht eine ausser Kontrolle geratene Erwärmung kaschieren kann.
if (state.currentTemp >= 18.5) stars = Math.min(stars, 1);
else if (state.currentTemp >= 17.0) stars = Math.min(stars, 3);
else if (state.currentTemp >= 16.3) stars = Math.min(stars, 4);
return stars;
}
function computeScore() {
const tempScore = Math.max(0, Math.min(35, 35 - (state.currentTemp - 15.5) * 20));
const floodScore = Math.max(0, 25 * (1 - state.floodedPct / 50)); // Ziel < 40 %: bei 40 % noch ~5 Pkt
const budgetScore = Math.max(0, Math.min(20, state.budget / 10));
const popScore = Math.max(0, Math.min(20, (state.population / state.diff.startPopulation) * 20));
return Math.round(tempScore + floodScore + budgetScore + popScore);
}
function renderStars(host, n) {
host.innerHTML = '';
for (let i = 0; i < 5; i++) {
const s = document.createElement('span');
s.className = 'ggs-star' + (i < n ? ' lit' : '');
s.textContent = '★';
host.appendChild(s);
}
}
function showBetweenLevel(stars) {
const q = REFLECTION_QUESTIONS[state.difficulty];
$('#level-emoji').textContent = stars >= 3 ? '🎉' : stars >= 1 ? '💪' : '😰';
$('#level-title').textContent = 'Level ' + state.difficulty + ' abgeschlossen!';
renderStars($('#level-stars'), stars);
const badgeHost = $('#level-badges');
badgeHost.innerHTML = '';
state.achievements.forEach(k => {
const a = ACHIEVEMENTS.find(x => x.key === k);
if (!a) return;
const el = document.createElement('div');
el.className = 'ggs-badge-earned';
el.title = a.title;
el.textContent = a.icon;
badgeHost.appendChild(el);
});
$('#reflection-question').textContent = q.q;
const optHost = $('#reflection-options');
optHost.innerHTML = '';
q.options.forEach((opt, idx) => {
const div = document.createElement('div');
div.className = 'ggs-reflection-option';
div.innerHTML = '<div class="ggs-reflection-radio"></div><span>'+opt+'</span>';
div.onclick = () => {
optHost.querySelectorAll('.ggs-reflection-option').forEach(o => o.classList.remove('selected'));
div.classList.add('selected');
submitReflection(q.q, opt, idx, q.options);
};
optHost.appendChild(div);
});
$('#level-next-btn').textContent = 'Nächstes Level →';
showOverlay('level-overlay');
}
function submitReflection(question, answer, index, options) {
if (!window.__GGS__ || !window.__GGS__.sessionId) return;
fetch((window.__GGS__.baseUrl || '') + '/php/api/progress.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
session_id: window.__GGS__.sessionId,
sim_id: 'klima',
action: 'reflection',
data: { level: state.difficulty, question, answer, answer_index: index, options }
})
}).catch(()=>{});
}
function goNextLevel() {
hideOverlay('level-overlay');
startGame(state.difficulty + 1);
}
function evalGoals() {
// Haupt-Ziele wie in game.ts (required: true). Jedes wird nur am Ende ausgewertet.
return [
{ title:'Bis 2175 überleben', achieved: state.tick >= state.maxTick },
{ title:'Temperatur unter +2 °C', achieved: state.currentTemp < 17 },
{ title:'Budget positiv', achieved: state.budget > 0 },
{ title:'Stadt schützen (< 40 % überflutet)', achieved: state.floodedPct < 40 },
];
}
function showEndScreen(stars, score, reason) {
const won = state.levelWon;
const goals = evalGoals();
const achievedCount = goals.filter(g => g.achieved).length;
const totalCount = goals.length;
$('#end-icon').textContent = won ? '🏆'
: reason === 'pleite' ? '💸'
: reason === 'ueberflutet' ? '🌊'
: reason === 'hitze' ? '🥵'
: '⚠️';
$('#end-title').textContent = won ? 'Mission erfüllt!'
: reason === 'pleite' ? 'Pleite!'
: reason === 'ueberflutet' ? 'Küste überflutet!'
: reason === 'hitze' ? 'Klima-Kollaps!'
: 'Simulation gescheitert';
$('#end-subtitle').textContent = won
? ('Du hast bis ' + (state.startYear + state.tick) + ' alle ' + totalCount + ' Hauptziele erreicht. Glückwunsch!')
: ('Im Jahr ' + (state.startYear + state.tick) + ' ist deine Mission gescheitert. ' + achievedCount + ' von ' + totalCount + ' Zielen erreicht.');
renderStars($('#end-stars'), stars);
// Goal-Liste mit ✓/✗ (wie 3D-Endscreen)
const goalsHtml = goals.map(g =>
'<div class="kw-end-goal ' + (g.achieved ? 'done' : 'fail') + '">'
+ '<span class="kw-end-goal-icon">' + (g.achieved ? '✓' : '✗') + '</span>'
+ '<span>' + g.title + '</span>'
+ '</div>'
).join('');
const stats = $('#end-stats');
stats.innerHTML =
'<div style="grid-column:1/-1"><div class="kw-end-goals">' + goalsHtml + '</div></div>'
+ '<div><div class="ggs-results-stat-value">' + state.tick + '</div><div class="ggs-results-stat-label">Jahre</div></div>'
+ '<div><div class="ggs-results-stat-value">' + state.currentTemp.toFixed(1) + ' °C</div><div class="ggs-results-stat-label">Endtemperatur</div></div>'
+ '<div><div class="ggs-results-stat-value">' + Math.round(state.budget) + ' Mio</div><div class="ggs-results-stat-label">Restbudget</div></div>'
+ '<div><div class="ggs-results-stat-value">' + Math.round(state.floodedPct) + ' %</div><div class="ggs-results-stat-label">Überflutet</div></div>'
+ '<div><div class="ggs-results-stat-value">' + score + '</div><div class="ggs-results-stat-label">Punkte</div></div>'
+ '<div><div class="ggs-results-stat-value">' + state.achievements.size + '</div><div class="ggs-results-stat-label">Erfolge</div></div>';
showOverlay('end-overlay');
}
/* ============================================================
API — LIVE PROGRESS
============================================================ */
function reportLiveProgress() {
if (!window.__GGS__ || !window.__GGS__.sessionId) return;
fetch((window.__GGS__.baseUrl || '') + '/php/api/progress.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
session_id: window.__GGS__.sessionId,
sim_id: 'klima',
action: 'update',
data: {
level: state.difficulty,
phase: state.phase,
tick: state.tick,
tick_max: state.maxTick,
score_so_far: computeScore(),
metrics: {
co2: Math.round(state.co2Ppm),
temperature: +state.currentTemp.toFixed(2),
budget: Math.round(state.budget),
flooded_pct: Math.round(state.floodedPct),
}
}
})
}).catch(()=>{});
}
/* ============================================================
AUTOSAVE / RESUME (Pflicht laut Interface-Doc)
— Nach jeder Aktion wird in localStorage + optional auf den Server
gespiegelt (/php/api/saves.php). Beim Seiten-Aufruf wird automatisch
geladen. Reset-Button löscht den Save mit Bestätigung.
============================================================ */
const SAVE_KEY_BASE = 'ggs-save-klima';
function currentSaveKey() {
return SAVE_KEY_BASE + '-' + state.difficulty;
}
function serializeState() {
// Engine serialisiert Simulations-State; View-Flags separat anhängen
const data = KlimaEngine.serialize(state);
data.tutorialStep = state.tutorialStep;
data.tutorialActive = state.tutorialActive;
data.phase = state.phase;
return data;
}
function deserializeState(d) {
if (!d || typeof d !== 'object') return false;
const loaded = KlimaEngine.deserialize(d);
if (!loaded) return false;
state = loaded;
let loadedPhase = d.phase || 'playing';
if (loadedPhase === 'end' || loadedPhase === 'tutorial') loadedPhase = 'playing';
Object.assign(state, {
phase: loadedPhase,
speed: 0, lastTickMs: 0,
MS_PER_TICK: 8000,
animMs: 0,
tutorialStep: d.tutorialStep || 0,
tutorialActive: !!d.tutorialActive,
pendingCitizenEvent: null,
});
const badge = $('#level-badge');
if (badge) badge.textContent = 'Level ' + state.difficulty + ' · ' + state.diff.label;
// Falls der Stand mitten im Bürger-Dialog gespeichert wurde (Refresh vor
// Entscheidung), Dialog erneut öffnen — Engine hat den Event noch nicht
// in citizenEventsFired abgelegt (erst bei applyCitizenChoice).
if (state.pendingCitizenEventId) {
const ev = KlimaEngine.getCitizenEvent(state.pendingCitizenEventId);
if (ev) setTimeout(() => triggerCitizenDialog(ev), 800);
}
return true;
}
let _saveTimer = 0;
function autoSave() {
// Debounce: max 1x pro 400 ms schreiben
if (_saveTimer) return;
_saveTimer = setTimeout(() => {
_saveTimer = 0;
const data = serializeState();
try { localStorage.setItem(currentSaveKey(), JSON.stringify(data)); } catch (e) {}
// Server-Spiegelung (falls Session vorhanden) — best effort
if (window.__GGS__ && window.__GGS__.sessionId) {
fetch((window.__GGS__.apiUrl || '') + '/saves.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: window.__GGS__.sessionId,
sim_id: 'klima',
save_key: 'level-' + state.difficulty,
save_data: data,
})
}).catch(() => {});
}
}, 400);
}
/** Versucht, beim Seiten-Start einen gespeicherten Stand zu laden.
* Rückgabe: true wenn geladen, sonst false. */
function tryResume() {
// Bei aktivem, noch nicht erledigtem Lehrer-Auftrag startet der Schüler
// immer frisch — alte Saves werden verworfen, damit die Auftragsstunde
// nicht aus einem alten Free-Play-Stand weiterläuft.
if (window.__GGS__ && window.__GGS__.skipResume) {
for (const lvl of [1, 2, 3]) localStorage.removeItem(SAVE_KEY_BASE + '-' + lvl);
return false;
}
// Level aus URL oder zuletzt gespeichert bestimmen
const urlLevel = parseInt(new URLSearchParams(location.search).get('level') || '0', 10);
const wantedLevel = (urlLevel >= 1 && urlLevel <= 3) ? urlLevel : null;
// Alle 3 möglichen Saves durchsuchen, passenden wählen
for (const lvl of (wantedLevel ? [wantedLevel] : [1, 2, 3])) {
const raw = localStorage.getItem(SAVE_KEY_BASE + '-' + lvl);
if (!raw) continue;
try {
const d = JSON.parse(raw);
if (!d || !d.tick || d.tick <= 0) continue;
// Beendete Läufe nie wieder aufnehmen — sonst landet „Nochmal spielen" /
// erneutes Öffnen direkt im Endbildschirm. Alten Stand verwerfen.
if (d.phase === 'end') { localStorage.removeItem(SAVE_KEY_BASE + '-' + lvl); continue; }
deserializeState(d);
hideOverlay('level-select-overlay');
state.phase = (d.phase === 'citizen' ? 'playing' : d.phase) || 'playing';
// Speed startet auf Pause, damit Schüler*in sich orientieren kann
state.speed = 0;
$$('.ggs-speed-btn').forEach(b => b.classList.remove('active'));
const pb = document.querySelector('[data-speed="0"]'); if (pb) pb.classList.add('active');
$('#header').classList.add('paused');
renderUI(true);
// Hinweis zeigen — aber erst nach Loading-Screen
setTimeout(() => showEvent('💾', 'Spielstand geladen — weiter bei Jahr ' + (state.startYear + state.tick), 'good'), 600);
return true;
} catch (e) { /* korrupter Save wird ignoriert */ }
}
return false;
}
function resetGame() {
if (!confirm('Fortschritt wirklich löschen und neu starten?')) return;
for (const lvl of [1, 2, 3]) localStorage.removeItem(SAVE_KEY_BASE + '-' + lvl);
// Kompletten Seiten-Reload, damit die Initialisierung sauber wieder greift.
location.reload();
}
$('#btn-reset').addEventListener('click', resetGame);
/* ============================================================
SOUND — Button-Hookup für Mute/Unmute. Klicks während des
Spiels werden über kwAudio.play(...) an passenden Stellen abgesetzt.
============================================================ */
(function initSoundButton() {
const btn = $('#btn-sound');
if (!btn || !window.kwAudio) return;
function refresh() { btn.textContent = kwAudio.isMuted() ? '🔇' : '🔊'; }
refresh();
btn.addEventListener('click', () => { kwAudio.toggleMute(); refresh(); });
})();
/* ============================================================
GLOSSAR — lädt Einträge aus der DB-API, Popup mit Link in neuem Tab
zur vollständigen Glossar-Seite (App/pages/glossar.php).
Das Hardcoded-Objekt GLOSSAR wird als Fallback verwendet, falls die
API einen Key (noch) nicht kennt.
============================================================ */
const GLOSSAR_API = (window.__GGS__ && window.__GGS__.apiUrl ? window.__GGS__.apiUrl : '/geograsim/App/php/api') + '/glossar.php';
// basePath ist auf Prod ein leerer String '' (Domain-Root). Truthy-Check
// würde dort fälschlich auf '/geograsim/App' fallen — daher typeof-Check.
const GLOSSAR_PAGE = ((window.__GGS__ && typeof window.__GGS__.basePath === 'string') ? window.__GGS__.basePath : '/geograsim/App') + '/glossar';
// API-Cache für bereits geladene Einträge (ergänzt das hardcoded Fallback)
const GLOSSAR_API_CACHE = {};
async function loadGlossarFromApi() {
try {
const res = await fetch(GLOSSAR_API + '?module=klima');
if (!res.ok) return;
const entries = await res.json();
if (!Array.isArray(entries)) return;
entries.forEach(e => {
GLOSSAR_API_CACHE[e.key_slug] = {
title: e.title,
text: e.text || e.short || '',
unit: e.unit || null,
fromApi: true,
};
});
} catch (err) { /* offline / DB nicht erreichbar → Fallback */ }
}
loadGlossarFromApi();
function resolveGlossarEntry(key) {
// 1. API-Cache (aktuell, zentral gepflegt), 2. Hardcoded Fallback
return GLOSSAR_API_CACHE[key] || GLOSSAR[key] || null;
}
/* Klima-Sim verwendet teils andere data-glossar-Keys als die zentrale
Glossar-DB. Damit der „Voller Glossar-Eintrag →"-Link nicht ins
Leere führt, hier die Übersetzung Klima-Key → DB-key_slug. Keys ohne
Eintrag im KLIMA_TO_DB-Mapping UND ohne Treffer in der API-Cache
bekommen keinen Glossar-Link mehr (statt einer toten URL). */
const KLIMA_TO_DB_KEY = {
windpark: 'windenergie',
solaranlage: 'photovoltaik',
erneuerbar: 'erneuerbare-energie',
kohlekraftwerk: 'fossile-brennstoffe',
};
const GLOSSAR_DB_KNOWN = new Set([
'albedo','biodiversitaet','co2','co2-aequivalent','co2-fussabdruck',
'emissionen','energiemix','erneuerbare-energie','fossile-brennstoffe',
'fotosynthese','kilowatt','kilowattstunde','kipppunkt','klimaneutralitaet',
'klimawandel','kohlenstoffkreislauf','megawatt','methan','nachhaltigkeit',
'pariser-abkommen','permafrost','photovoltaik','ppm','treibhauseffekt',
'treibhausgas','wasserkraft','watt','windenergie',
// Klima-Sim-Begriffe (Migration 2026-05-02): 4 Aliasse + 14 Volleinträge
'windpark','solaranlage','erneuerbar','kohlekraftwerk',
'budget','strom','mangrove','klimafolgen','kuestenschutz',
'wartung','tourismus','blackout','steuern','temperatur',
'meeresspiegel','bevoelkerung','ueberflutung','co2_filter',
]);
function resolveGlossarDbKey(key) {
const aliased = KLIMA_TO_DB_KEY[key] || key;
if (GLOSSAR_DB_KNOWN.has(aliased)) return aliased;
if (GLOSSAR_API_CACHE[aliased]) return aliased; // dynamisch nachgeladen
return null;
}
let _glossarPopup = null;
document.addEventListener('click', (e) => {
if (_glossarPopup && !_glossarPopup.contains(e.target)) {
_glossarPopup.remove(); _glossarPopup = null;
}
const el = e.target.closest('[data-glossar]');
if (!el) return;
// Innerhalb klickbarer Action-Cards KEIN Glossar-Popup öffnen —
// da will die Spieler*in kaufen, nicht nachschlagen. Die Cards haben
// sowieso ein title-Attribut mit dem vollen Namen als Browser-Tooltip.
if (el.closest('.ggs-card')) return;
const key = el.dataset.glossar;
const entry = resolveGlossarEntry(key);
if (!entry) return;
if (_glossarPopup) { _glossarPopup.remove(); _glossarPopup = null; }
const popup = document.createElement('div');
popup.className = 'ggs-glossar-popup';
let html = '<div class="ggs-glossar-title">'+entry.title+'</div>';
html += '<div class="ggs-glossar-text">'+entry.text+'</div>';
if (entry.unit) html += '<div class="ggs-glossar-unit">'+entry.unit+'</div>';
// Voller Eintrag im Glossar — nur verlinken, wenn der Begriff in der
// zentralen Glossar-DB existiert (Alias-Mapping inklusive).
const dbKey = resolveGlossarDbKey(key);
if (dbKey) {
html += '<br><a href="'+GLOSSAR_PAGE+'?term='+encodeURIComponent(dbKey)+'" target="_blank" rel="noopener" class="ggs-glossar-link">Voller Glossar-Eintrag →</a>';
}
if (entry.link && !entry.fromApi) {
html += (dbKey ? ' ' : '<br>') + '<a href="'+entry.link+'" target="_blank" rel="noopener" class="ggs-glossar-link"' + (dbKey ? ' style="margin-left:8px"' : '') + '>Wikipedia →</a>';
}
popup.innerHTML = html;
document.body.appendChild(popup);
_glossarPopup = popup;
const rect = el.getBoundingClientRect();
const pw = popup.offsetWidth;
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8)) + 'px';
popup.style.top = (rect.bottom + 8) + 'px';
});
/* ============================================================
GRAPH ZOOM (klickbar)
============================================================ */
/* --- Graph-Info-Buttons + Unit-Chips → Info-Overlay --- */
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-info]');
if (!btn) return;
e.stopPropagation();
const key = btn.getAttribute('data-info');
if (key && INFO_TOPICS[key]) openInfoOverlay(key);
});
/* --- Graph-Zoom: Klick auf Card vergrößert (ohne Info-Buttons/Unit-Chips).
Die Y-Achsen-Labels, Zonen-Beschriftungen und Referenzlinien-Labels liegen
als HTML-Overlay in `.ggs-graph-axis` — beim Zoom müssen wir sie
mitklonen, sonst zeigt die Zoom-Ansicht nur Shapes ohne Text. */
$$('.ggs-graph-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('[data-info]')) return;
const label = card.querySelector('.ggs-graph-label');
const svg = card.querySelector('.ggs-graph-svg');
const axis = card.querySelector('.ggs-graph-axis');
const liveVal = card.querySelector('.kw-graph-live-val');
if (!svg) return;
const overlay = document.createElement('div');
overlay.className = 'ggs-graph-zoom-overlay';
overlay.onclick = (ev) => { if (ev.target === overlay) overlay.remove(); };
const zoom = document.createElement('div');
zoom.className = 'ggs-graph-zoom-card';
const header = document.createElement('div');
header.className = 'ggs-graph-zoom-header';
header.innerHTML = '<div class="ggs-graph-label">' + (label?label.textContent:'') + '</div>';
zoom.appendChild(header);
// SVG + Achsen-Overlay in einen gemeinsamen Relativ-Container packen,
// damit die absolut positionierten Labels sich auf die neue Größe beziehen.
const stage = document.createElement('div');
stage.className = 'ggs-graph-card'; // gleiche Klasse → ggs-graph-axis-* greift
stage.style.cssText = 'position:relative;flex:1;cursor:default;padding:0;border:none;background:transparent;box-shadow:none;margin:0;';
const bigSvg = svg.cloneNode(true);
bigSvg.style.height = '100%';
bigSvg.style.width = '100%';
bigSvg.style.flex = '1';
bigSvg.setAttribute('preserveAspectRatio', 'none'); // Streckt Shapes auf volle Fläche
stage.appendChild(bigSvg);
if (axis) {
const clonedAxis = axis.cloneNode(true);
stage.appendChild(clonedAxis);
}
if (liveVal) {
const clonedVal = liveVal.cloneNode(true);
stage.appendChild(clonedVal);
}
zoom.appendChild(stage);
overlay.appendChild(zoom);
document.body.appendChild(overlay);
const closeOnEsc = (ev) => { if (ev.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', closeOnEsc); } };
document.addEventListener('keydown', closeOnEsc);
});
});
/* ============================================================
INITIAL STARTUP
============================================================ */
window.addEventListener('load', () => {
// Modul-Metadaten aus dem PHP-Kontext (DB-verwaltet) — falls vorhanden,
// überschreiben sie alle hartkodierten Titel/Icons im HTML.
const ctxName = window.__GGS__ && window.__GGS__.simName;
const ctxIcon = window.__GGS__ && window.__GGS__.simIcon;
if (ctxName) {
document.title = 'GeoGraSim — ' + ctxName;
document.querySelectorAll('[data-ggs-name]').forEach(el => { el.textContent = ctxName; });
}
if (ctxIcon) {
document.querySelectorAll('[data-ggs-icon]').forEach(el => { el.textContent = ctxIcon; });
}
renderUI();
resizeCanvas(); drawCanvas();
// Loading-Screen ausblenden
setTimeout(() => {
const ls = $('#loading-screen');
if (ls) { ls.classList.add('fade-out'); setTimeout(() => ls.remove(), 600); }
}, 500);
// PFLICHT: Erst prüfen, ob ein laufender Spielstand existiert → dann fortsetzen.
// Sonst: URL/PHP-Level auswerten oder Level-Select anzeigen.
setTimeout(() => {
if (tryResume()) return;
const forced = window.__GGS__ && window.__GGS__.level && window.__GGS__.sessionId;
const urlLevel = new URLSearchParams(location.search).get('level');
const lvl = forced ? window.__GGS__.level : (urlLevel ? parseInt(urlLevel, 10) : null);
if (lvl && DIFFICULTY[lvl]) {
startGame(lvl);
} else {
state.phase = 'level-select';
showOverlay('level-select-overlay');
}
}, 700);
// Lehrplan-Link im Header dynamisch setzen
(function() {
var link = document.getElementById('ggs-lehrplan-link');
if (!link) return;
var simId = (window.__GGS__ && window.__GGS__.simId) || 'klima';
var baseUrl = (window.__GGS__ && window.__GGS__.baseUrl) || '';
link.href = baseUrl + '/modul-' + simId + '#lehrplan-bezug';
})();
// Hintergrundmusik-Player initialisieren
ggsMusicSetup([
{ key: 'lounge', label: '🎹 Lounge', file: 'assets/music/piano-lobby.mp3' },
{ key: 'ambient', label: '🌿 Ambient', file: 'assets/music/fingertip-rain.mp3' },
{ key: 'ruhig', label: '🌙 Ruhig', file: 'assets/music/limestone-lullaby.mp3' },
{ key: 'chill', label: '☕ Chill', file: 'assets/music/vibrocoffee-calm.mp3' },
{ key: 'drama', label: '🎭 Drama', file: 'assets/music/rising-pressure.mp3' }
]);
});
/* --- Music Player (Standard aus Design-System) ---
Modul ruft einmalig ggsMusicSetup([{key,label,file},...]) auf. */
window.ggsMusicSetup = function(tracks) {
var el = document.getElementById('ggs-music');
var sel = document.getElementById('ggs-music-sel');
var btn = document.getElementById('ggs-music-toggle');
var vol = document.getElementById('ggs-music-vol');
if (!el || !tracks || !tracks.length) return;
el.hidden = false;
var audio = new Audio();
audio.loop = true;
var simId = (window.__GGS__ && window.__GGS__.simId) || 'klima';
var lsKey = 'ggs-music-' + simId;
var state = JSON.parse(localStorage.getItem(lsKey) || '{}');
sel.innerHTML = tracks.map(function(t) {
return '<option value="'+t.key+'" data-file="'+t.file+'">'+t.label+'</option>';
}).join('');
// Beim Initial-Load nie mit Drama starten — das ist Krisen-Musik.
if (state.track && state.track !== 'drama') sel.value = state.track;
audio.src = sel.options[sel.selectedIndex].dataset.file;
// Default-Lautstärke bewusst im unteren Drittel (22 %) — Musik soll
// unter den SFX/Spiel-Tönen liegen, nicht über ihnen.
var defaultVol = 22;
audio.volume = (state.volume != null ? state.volume : defaultVol) / 100;
vol.value = state.volume != null ? state.volume : defaultVol;
function save() {
localStorage.setItem(lsKey, JSON.stringify({
track: sel.value, volume: +vol.value, playing: !audio.paused
}));
}
// Manuelle Track-Wahl blockiert den Auto-Switch (z.B. Drama bei Defizit).
// Wer sich aktiv eine Stimmung aussucht, bekommt sie — Spiel mischt sich
// nicht ein. Erst ein Reload/Level-Start setzt den Override zurück.
var _manualMusicOverride = false;
var _preAutoTrack = null; // Track, der VOR dem Auto-Switch lief
sel.addEventListener('change', function() {
_manualMusicOverride = true;
_preAutoTrack = null;
var wasPlaying = !audio.paused;
audio.src = sel.options[sel.selectedIndex].dataset.file;
if (wasPlaying) audio.play().catch(function(){});
save();
});
function switchTo(key) {
if (sel.value === key) return;
var opt = Array.prototype.find.call(sel.options, function(o) { return o.value === key; });
if (!opt) return;
var wasPlaying = !audio.paused;
sel.value = key;
audio.src = opt.dataset.file;
if (wasPlaying) audio.play().catch(function(){});
save();
}
// Öffentliche API für automatisches Wechseln bei Krisen-Situationen.
// `ggsMusicSetAuto('drama')` wechselt, falls der Spieler nicht selbst
// bewusst etwas ausgewählt hat. `ggsMusicRestoreAuto()` stellt den
// Track wieder her, den der Spieler davor gehört hatte.
window.ggsMusicSetAuto = function(key) {
if (_manualMusicOverride) return;
if (sel.value === key) return;
if (!_preAutoTrack) _preAutoTrack = sel.value;
switchTo(key);
};
window.ggsMusicRestoreAuto = function() {
if (_manualMusicOverride) return;
if (_preAutoTrack && sel.value !== _preAutoTrack) {
switchTo(_preAutoTrack);
}
_preAutoTrack = null;
};
window.ggsMusicResetManualOverride = function() {
_manualMusicOverride = false;
_preAutoTrack = null;
// Spielbeginn: nie mit Drama starten. Wenn der vorige Run die Krise
// erreicht hat (Auto-Switch oder localStorage gespeichert),
// automatisch auf einen nicht-dramatischen Track wechseln.
if (sel.value === 'drama') {
var nonDrama = Array.prototype.find.call(sel.options, function(o){ return o.value !== 'drama'; });
if (nonDrama) switchTo(nonDrama.value);
}
};
btn.addEventListener('click', function() {
if (audio.paused) {
audio.play().then(function() {
btn.textContent = '⏸';
btn.classList.add('playing');
save();
}).catch(function(){});
} else {
audio.pause();
btn.textContent = '▶';
btn.classList.remove('playing');
save();
}
});
vol.addEventListener('input', function() {
audio.volume = vol.value / 100;
save();
});
// Auto-Start nach erster User-Geste (Browser-Autoplay-Policy).
// Wenn der User die Musik zuletzt ausdrücklich gestoppt hatte (state.playing
// === false), starten wir NICHT automatisch. Ohne expliziten Stop-Vermerk
// (erster Besuch oder zuletzt gespielt) beginnt die Musik mit der ersten
// Interaktion leise im Hintergrund.
var shouldAutostart = state.playing !== false;
if (shouldAutostart) {
var resume = function() {
audio.play().then(function() {
btn.textContent = '⏸';
btn.classList.add('playing');
save();
}).catch(function(){});
document.removeEventListener('click', resume);
document.removeEventListener('keydown', resume);
document.removeEventListener('touchstart', resume);
};
document.addEventListener('click', resume, { once: true });
document.addEventListener('keydown', resume, { once: true });
document.addEventListener('touchstart', resume, { once: true });
}
};
</script>
</body>
</html>