502f271f36
- 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>
4578 lines
188 KiB
HTML
4578 lines
188 KiB
HTML
<!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-Hauptstriche 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 Wien–New 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 (0–100, 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 => ({'<':'<','>':'>','&':'&'}[c])) + '</strong>'
|
||
+ (desc ? '<br>' + desc.replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
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>
|