0fe4377deb
- Airport-Fahrzeuge: pro Flughafen eigener Heli + Flugzeug, in airportVehicles-Map verwaltet, über syncMeasureMeshes mit-synchronisiert. Phasen-Offsets per Airport-Index, damit mehrere Flughäfen nicht synchron laufen. - Heli: landet jetzt korrekt auf dem H (padLocal aus userData statt hardcoded, inkl. LIFT-Korrektur). Alle ~3 Zyklen (deterministisch) Vulkan-Runde statt Airport-Kreis — Radius 9 um VOLCANO_X/Z. - Flugzeug (makePlane, low-poly Cessna): 110-s-Zyklus — parkt am Runway-Anfang, Takeoff-Roll, Climb-Out nach -X, 50 s unsichtbar (Reise), Approach, Landing-Roll, Taxi. Propeller dreht. - Wolken: Höhe auf ~7 abgesenkt (Vulkan-Gletscher-Höhe), fühlen sich jetzt wie Dunst um den Kegel an statt Himmels-Schleier. - Flut-Tönung: applyFloodTintAll lerpt Material-Farben aller Mesh- Füße, die unter ocean.position.y liegen, Richtung Dunkelgrau und kippt sie bis 0.12 rad. Wirkt auf Maßnahmen + Dorf + Citizen-Strand. - Graph-Top = verloren: neu endLevel(false, 'co2-top' | 'temp-top') wenn state.co2Ppm ≥ 700 oder state.currentTemp ≥ 19. Endscreen bekommt passende Icons (🌫 / 🌡) und Titel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5448 lines
228 KiB
HTML
5448 lines
228 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>GeoGraSim — Klimawächter 3D</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">
|
||
<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);
|
||
}
|
||
/* 3D-Szene: Grid-Item, das Status-, Sim- und Actions-Spalte randlos überspannt.
|
||
Die Panels sitzen mit z-index:1 als Glasflächen darüber, Graphen-Zeile bleibt
|
||
solide am Fuß. Der Ozean läuft so bis an den linken/rechten Bildschirmrand,
|
||
die Insel bleibt im Standard-Zoom in der Mittelspalte sichtbar. */
|
||
#scene-container {
|
||
grid-column: 1 / -1;
|
||
grid-row: 1;
|
||
z-index: 0;
|
||
position: relative;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
background: linear-gradient(to bottom, #9ac8dc 0%, #bfdceb 55%, #4a7c8a 100%);
|
||
overflow: hidden;
|
||
}
|
||
#scene-container canvas {
|
||
display: block;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
/* --- Glas-Overlay-Layout für die 3D-Variante ---
|
||
Status- und Actions-Panels schweben mit leichter Transparenz über der Insel,
|
||
damit der Übergang von Panel zu Szene nicht als harter Schnitt wirkt.
|
||
Sim-Zone ist transparent und lässt Klicks durch — nur ihre UI-Kinder
|
||
(Topbar, Timeline, Event-Viewport, Event-Stack) fangen Pointer ab. */
|
||
.ggs-sim-layout > .ggs-zone-status,
|
||
.ggs-sim-layout > .ggs-zone-actions {
|
||
grid-row: 1;
|
||
z-index: 1;
|
||
background: rgba(255, 255, 255, 0.38);
|
||
backdrop-filter: blur(20px) saturate(160%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||
}
|
||
.ggs-sim-layout > .ggs-zone-status { border-right: 1px solid rgba(255,255,255,0.55); }
|
||
.ggs-sim-layout > .ggs-zone-actions { border-left: 1px solid rgba(255,255,255,0.55); }
|
||
|
||
.ggs-sim-layout > .ggs-zone-sim {
|
||
grid-row: 1;
|
||
z-index: 1;
|
||
background: transparent;
|
||
pointer-events: none;
|
||
}
|
||
.ggs-sim-layout > .ggs-zone-sim > * { pointer-events: auto; }
|
||
|
||
/* Timeline bekommt eigene Glasfläche, damit die Jahreszahlen auf dem Himmel
|
||
lesbar bleiben. */
|
||
.kw-timeline {
|
||
background: rgba(255, 255, 255, 0.72);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-radius: var(--ggs-radius-sm);
|
||
padding: 0 8px;
|
||
}
|
||
/* Placement-Ghost-Hinweis unter dem Cursor (rein visuell, Pointer geht durch) */
|
||
.kw-place-hint {
|
||
position: absolute;
|
||
top: 8px; left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(30,40,50,0.82);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
pointer-events: none;
|
||
z-index: 4;
|
||
opacity: 0;
|
||
transition: opacity .18s var(--ggs-ease);
|
||
}
|
||
.kw-place-hint.visible { opacity: 1; }
|
||
.kw-place-hint.invalid { background: rgba(176,74,58,0.92); }
|
||
|
||
/* Zentrales Pause-Zeichen über der Szene — erscheint bei Speed 0 */
|
||
.kw-pause-center {
|
||
position: absolute;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 5;
|
||
pointer-events: auto;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
text-align: center;
|
||
animation: kw-pause-fade-in 0.2s ease-out;
|
||
}
|
||
.kw-pause-btn {
|
||
width: 110px; height: 110px;
|
||
border-radius: 50%;
|
||
background: rgba(30, 40, 50, 0.58);
|
||
backdrop-filter: blur(10px) saturate(140%);
|
||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||
color: #fff;
|
||
font-size: 56px;
|
||
line-height: 110px;
|
||
border: 3px solid rgba(255,255,255,0.78);
|
||
box-shadow: 0 6px 20px rgba(0,0,0,0.28);
|
||
transition: transform 0.15s var(--ggs-ease), background 0.18s;
|
||
text-indent: 8px; /* optische Mitte für die ▶-Spitze */
|
||
}
|
||
.kw-pause-center:hover .kw-pause-btn {
|
||
transform: scale(1.08);
|
||
background: rgba(74, 124, 138, 0.75);
|
||
}
|
||
.kw-pause-label {
|
||
margin-top: 10px;
|
||
display: inline-block;
|
||
background: rgba(30, 40, 50, 0.72);
|
||
color: #fff;
|
||
padding: 5px 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
@keyframes kw-pause-fade-in {
|
||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.85); }
|
||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||
}
|
||
|
||
/* „Bau beenden"-Karte — kompakt, kontrastfarben, nur während Placement */
|
||
.kw-exit-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
margin-bottom: 6px;
|
||
border-radius: var(--ggs-radius-sm);
|
||
background: linear-gradient(135deg, var(--ggs-coral), var(--ggs-coral-dark, #a34030));
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
border: 1px solid rgba(0,0,0,0.1);
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||
transition: transform 0.12s var(--ggs-ease), background 0.18s;
|
||
}
|
||
.kw-exit-card:hover { transform: translateY(-1px); background: linear-gradient(135deg, #c85a4a, #8a3a28); }
|
||
.kw-exit-card .kw-exit-icon { font-size: 15px; }
|
||
.kw-exit-card .kw-exit-label { flex: 1; }
|
||
.kw-exit-card .kw-exit-hint { font-size: 10px; opacity: 0.85; background: rgba(0,0,0,0.25); padding: 2px 6px; border-radius: 3px; }
|
||
|
||
/* Experten-Fragen: aktiver Button im Header sichtbar markiert */
|
||
#btn-expert.active {
|
||
background: var(--ggs-fjord);
|
||
color: #fff;
|
||
box-shadow: 0 0 0 2px var(--ggs-fjord-light) inset;
|
||
}
|
||
|
||
|
||
/* 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); }
|
||
|
||
.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;
|
||
}
|
||
</style>
|
||
<script src="engine.js"></script>
|
||
<script src="audio.js"></script>
|
||
<script src="https://unpkg.com/three@0.155.0/build/three.min.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ============================================================
|
||
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">
|
||
<a href="../../" 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 3D</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>
|
||
<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-expert" title="Experten fragen (kostenlos) — auf ein Objekt in der Szene klicken">👨🔬</button>
|
||
<button class="ggs-btn-ghost" id="btn-sound" title="SFX an/aus">🔊</button>
|
||
<button class="ggs-btn-ghost" id="btn-reset" title="Neu starten (Arbeitsstand löschen)">🔄</button>
|
||
<a href="../../" class="ggs-btn-ghost" title="Zurück">🏠</a>
|
||
</div>
|
||
</header>
|
||
|
||
|
||
<!-- ============================================================
|
||
MAIN LAYOUT
|
||
============================================================ -->
|
||
<main class="ggs-sim-layout">
|
||
|
||
<!-- 3D-SZENE (läuft als Grid-Item unter Status- und Actions-Panels durch,
|
||
von Seitenrand zu Seitenrand — Panels schweben als Glasflächen drüber) -->
|
||
<div id="scene-container">
|
||
<div class="kw-place-hint" id="place-hint">Klicke zum Platzieren</div>
|
||
<div class="kw-pause-center" id="pause-center" style="display:none" title="Fortsetzen">
|
||
<div class="kw-pause-btn">▶</div>
|
||
<div class="kw-pause-label">Pause — klicken zum Fortsetzen</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<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">
|
||
<div class="ggs-panel">
|
||
<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 3D</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 zum Cockpit</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;flex-wrap:wrap">
|
||
<button class="ggs-btn ggs-btn-secondary" onclick="hideOverlay('end-overlay')">Weiter arbeiten</button>
|
||
<button class="ggs-btn ggs-btn-primary" onclick="startFreshSimulation()">Neue Simulation</button>
|
||
<button class="ggs-btn ggs-btn-secondary" onclick="location.href='../../'">Zurück zum Cockpit</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-3d.html → basePath = .../App
|
||
const path = location.pathname.replace(/\/sims\/klima\/game-3d\.html$/, '');
|
||
window.__GGS__ = {
|
||
sessionId: '', studentName: 'Gast', classId: null,
|
||
simId: 'klima', simName: 'Klimawächter 3D', 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' },
|
||
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' },
|
||
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 die Simulation schneller laufen lassen ⏩.<br><br>'
|
||
+'Deine Aufgabe: bis 2175 durchhalten, Temperatur unter +2 °C halten, Budget im Plus halten, die Stadt schützen.<br><br>'
|
||
+'Viel Erfolg! 🌍' }
|
||
];
|
||
|
||
/* ============================================================
|
||
INFO-TOPICS (kinderfreundliche Erklärungen, wie in 3D-Version)
|
||
============================================================ */
|
||
|
||
const INFO_TOPICS = {
|
||
// --- Maßnahmen (für Experten-Modus) ---
|
||
forest_info: {
|
||
title: '🌲 Wald aufforsten',
|
||
text: 'Bäume nehmen CO₂ aus der Luft auf und speichern es in Holz und Boden. Wald ist die günstigste Naturlösung — ein einziger Baum bindet etwa 10 kg CO₂ pro Jahr, ein ausgewachsener Wald viele Tonnen.\n\nNachteil: Er wächst langsam. Es dauert Jahrzehnte, bis ein neuer Wald richtig viel CO₂ bindet.',
|
||
hint: 'Je mehr Wald, desto mehr CO₂-Senke. In dieser Simulation bringt jeder Wald −0,04 ppm pro Jahr.',
|
||
},
|
||
solar_info: {
|
||
title: '☀️ Solaranlage',
|
||
text: 'Photovoltaik wandelt Sonnenlicht direkt in Strom um. Eine Solaranlage produziert sauberen Strom ohne CO₂-Ausstoß und ohne Lärm — die Sonne schickt keine Rechnung.\n\nÖsterreich hat 2023 schon über 3.000 MW Solarleistung installiert — Tendenz stark steigend.',
|
||
hint: 'Funktioniert nur tagsüber und bei gutem Wetter. In der Mischung mit Wind perfekt.',
|
||
},
|
||
wind_info: {
|
||
title: '🌬 Windpark',
|
||
text: 'Moderne Windräder liefern pro Stück 3 bis 6 MW. Der Rotor wandelt die Bewegungsenergie des Windes in Strom. Pro gewonnener Kilowattstunde entsteht nur etwa 10 g CO₂ — 100-mal weniger als bei Kohlestrom.\n\nOffshore-Windparks (auf dem Meer) werden immer wichtiger, weil der Wind dort stärker und gleichmäßiger weht.',
|
||
hint: 'In dieser Simulation: Windpark = 3 MW sauberer Strom und −0,5 ppm CO₂ pro Jahr.',
|
||
},
|
||
green_roof_info: {
|
||
title: '🏡 Gründach',
|
||
text: 'Ein begrüntes Dach speichert Regenwasser, kühlt das Haus im Sommer und bindet CO₂. Bei Hitzewellen sind Gründächer deutlich kühler als klassische Dächer.\n\nIn Städten helfen Gründächer gegen den „Wärmeinseleffekt" — heiße Städte werden weniger heiß.',
|
||
hint: 'Einmalig 30 Mio € Kosten, keine Wartung — kleine Maßnahme mit gutem Effekt.',
|
||
},
|
||
dike_info: {
|
||
title: '🌊 Deich',
|
||
text: 'Ein Deich ist ein künstlicher Damm aus Erde und Steinen, der das Meer zurückhält. In den Niederlanden leben viele Menschen unter Meeresspiegel — nur dank Deichen.\n\nDeiche schützen schnell und zuverlässig, aber sie müssen ständig gepflegt werden (3 Mio €/Jahr Wartung).',
|
||
hint: '+12 cm Küstenschutz — reicht für langsamen Meeresanstieg, nicht für Sturmfluten bei +1 m.',
|
||
},
|
||
sea_wall_info: {
|
||
title: '🛡 Hochwasserschutz',
|
||
text: 'Eine massive Betonmauer direkt an der Küste. Deutlich teurer als ein Deich, aber auch viel wirksamer — und schützt vor Sturmfluten, die einen Deich überspülen würden.\n\nVenedig hat ein berühmtes Hochwasserschutzsystem namens MOSE (Bewegliche Barriere).',
|
||
hint: '+50 cm Schutz für 240 Mio € — pro cm billiger als der Deich, aber höhere Wartung.',
|
||
},
|
||
mangrove_info: {
|
||
title: '🪸 Mangrovenwald',
|
||
text: 'Mangroven sind salzwasser-verträgliche Bäume an tropischen Küsten. Sie binden CO₂ UND schützen das Land vor Wellen und Erosion — zwei Klimaschutz-Jobs in einem.\n\nIn Indonesien, Bangladesch und Vietnam retten Mangrovenwälder jedes Jahr Millionen von Menschen vor Sturmfluten.',
|
||
hint: 'Kippen aber ab etwa 30–70 cm Meeresanstieg um: sie können dann nicht mehr schnell genug mitwachsen und ersticken.',
|
||
},
|
||
sand_fill_info: {
|
||
title: '🏖 Sand-Aufschüttung',
|
||
text: 'Schiffe pumpen Sand vom Meeresgrund auf einen schmal gewordenen Strand. Sofort sichtbarer Erfolg — der Strand ist wieder da.\n\nAber: Das Meer spült den neuen Sand jedes Jahr wieder weg. Alle paar Jahre muss neu aufgeschüttet werden. Ein Strand ist nichts, was man kauft und behält.',
|
||
hint: '+18 cm Schutz für 50 Mio € — aber bis zum nächsten Jahr ist schon wieder etwas weg.',
|
||
},
|
||
bikes_info: {
|
||
title: '🚲 Radwege-Netz',
|
||
text: 'Sichere Radwege ersetzen Autofahrten. Ein Rad verursacht pro Kilometer etwa 20-mal weniger CO₂ als ein Auto, und gar keines beim Fahren. Gut fürs Klima, gut für die Fitness, leise.\n\nLänder mit dichtem Radwegenetz (Niederlande, Dänemark) haben deutlich weniger Verkehrs-CO₂.',
|
||
hint: 'Kleine Wirkung im Einzelnen, aber skalierbar — je mehr Radwege, desto mehr Umsteiger.',
|
||
},
|
||
// --- Umgebung (für Experten-Modus) ---
|
||
island_info: {
|
||
title: '🏝 Deine Insel',
|
||
text: 'Eine kleine Inselstadt im Atlantik — fiktiv, aber mit realen Problemen. Hinten der Vulkan mit seinem Gletscher, vorne der Strand, in der Mitte Siedlungen und Felder.\n\nAchte auf die Höhenzonen: Strand (hell), Wiese (mittel), Wald (dunkel), Fels (grau). Maßnahmen passen nur an bestimmte Stellen.',
|
||
},
|
||
ocean_info: {
|
||
title: '🌊 Der Ozean',
|
||
text: 'Das Meer um deine Insel. Zu 97 % Salzwasser, durchschnittlich 4 km tief. Es speichert über 90 % der Wärme, die durch den Klimawandel in der Erde landet — und dehnt sich dabei aus. Das ist einer der Gründe, warum der Meeresspiegel steigt.',
|
||
hint: 'Das Wasser steigt in dieser Simulation sichtbar mit — je wärmer, desto höher.',
|
||
},
|
||
volcano_info: {
|
||
title: '🌋 Vulkan mit Gletscher',
|
||
text: 'Ein schlafender Vulkan mit Eiskappe. Die Eiskappe ist dein Trinkwasser-Speicher: Im Sommer schmilzt etwas Eis, das Wasser läuft in die Dörfer.\n\nBei steigender Temperatur schmilzt die Kappe immer schneller. Siehst du, wie sie dünner wird? Spätestens wenn sie verschwunden ist, fehlt den Feldern das Wasser.',
|
||
hint: 'Bei +2 °C ist der Gletscher zu etwa einem Drittel geschmolzen, bei +3 °C fast weg.',
|
||
},
|
||
tanker_info: {
|
||
title: '🚢 Frachter am Horizont',
|
||
text: 'Containerschiffe bringen Waren über die Meere. Sie fahren mit Schweröl, dem dreckigsten Kraftstoff überhaupt — Pro Kilometer stößt so ein Schiff riesige CO₂-Mengen aus.\n\nDer internationale Frachtverkehr ist für etwa 3 % aller CO₂-Emissionen weltweit verantwortlich.',
|
||
hint: 'Echte Alternativen: Wind-Antrieb (Flettner-Rotoren), E-Fuels, weniger Fern-Transporte.',
|
||
},
|
||
sailboat_info: {
|
||
title: '⛵ Segelboot',
|
||
text: 'Segelboote brauchen keinen Treibstoff — sie nutzen den Wind. Null CO₂-Ausstoß beim Fahren.\n\nFrüher waren Segelschiffe die einzige Transportmöglichkeit über das Meer. Heute denken manche darüber nach, Frachtschiffe wieder mit Segeln zu bauen, um Sprit zu sparen.',
|
||
},
|
||
fishing_info: {
|
||
title: '🎣 Fischerboot',
|
||
text: 'Das Fischerboot auf deiner Insel fängt Fisch für die Bevölkerung. Aber: Wenn das Wasser wärmer wird, ziehen viele Fischarten in kältere Gebiete — die Fischer stehen mit leeren Netzen da.\n\nKabeljau, Hering und Makrele sind in Nord- und Ostsee schon weit in den Norden gewandert.',
|
||
},
|
||
heli_info: {
|
||
title: '🚁 Rettungshubschrauber',
|
||
text: 'Sobald du einen Flughafen gebaut hast, verkehrt hier auch ein Rettungshubschrauber. Er kreist über der Insel und landet gelegentlich auf dem Heliport neben dem Terminal.\n\nHubschrauber brauchen viel Sprit pro Passagier — sie machen nur Sinn, wo Straßen oder Schiffe nicht hinkommen.',
|
||
},
|
||
village_info: {
|
||
title: '🏘 Dorf',
|
||
text: 'Kleine Siedlung mit Häusern und Schornsteinen. Solange deine Stromversorgung mit Kohle läuft, qualmen alle Schornsteine. Je mehr erneuerbare Energie du zubaust, desto weniger Rauch.\n\nTipp: Schau genau hin — mit jedem Windpark und Solarfeld werden die Rauchfahnen dünner.',
|
||
},
|
||
shield_info: {
|
||
title: '🏔 Lawinen-Schutzwall',
|
||
text: 'Wenn Anna vom Bergdorf dich um Hilfe bittet und du ihr den Schutzwall finanzierst, erscheint diese Steinbarriere am Fuß des Vulkans. Sie schützt das Bergdorf vor Schmelzwasser-Muren und bringt +4 cm Küstenschutz.',
|
||
},
|
||
|
||
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, ist dein Geld aufgebraucht — dann ist dein Durchgang beendet.',
|
||
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: 'unter 0' }],
|
||
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);
|
||
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;
|
||
// Cap bei 6 sichtbaren Toasts: älteste fällt raus, wenn eine neue kommt.
|
||
while (vp.children.length >= 6) vp.removeChild(vp.firstElementChild);
|
||
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);
|
||
setTimeout(() => { if (n.parentNode) n.parentNode.removeChild(n); }, 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); }
|
||
|
||
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.
|
||
*
|
||
* 3D-Flow: Klick auf Action-Card startet Placement-Mode (Ghost folgt Maus).
|
||
* Nach Linksklick auf valide Stelle wird die Engine aufgerufen — erst dort
|
||
* entscheidet sich, ob Budget reicht oder negative Jahresbilanz warnen muss.
|
||
* - Fehlende Mittel → Toast + ui-error Sound (vor Placement-Start)
|
||
* - Negative Bilanz → confirm()-Dialog, bei OK erneut mit forceNegBalance
|
||
* - Erfolg → engine mutiert state; Events, Achievements, Sound, Render + Save
|
||
*/
|
||
function buyMeasure(id) {
|
||
// Vorab-Check: Budget reicht? Sonst Placement gar nicht starten.
|
||
const m = KlimaEngine.findMeasure(id);
|
||
const cost = m ? Math.round(m.cost * state.researchDiscount) : 0;
|
||
if (state.budget < cost) {
|
||
showEvent('❗', 'Nicht genug Budget für ' + (m ? m.name : id), 'bad');
|
||
if (window.kwAudio) kwAudio.play('ui-error');
|
||
return false;
|
||
}
|
||
startPlacement(id, (placedId, hitPos) => finalizeBuy(placedId, hitPos));
|
||
return true;
|
||
}
|
||
|
||
function finalizeBuy(id, hitPos) {
|
||
const rotation = +placementRotation.toFixed(3);
|
||
const zone = MEASURE_ZONES_3D[id];
|
||
const zoneId = zone ? zone.kind : 'land';
|
||
const resolveMeta = () => ({ pos: hitPos, rotation, zoneId });
|
||
let res = KlimaEngine.buyMeasure(state, id, { resolveMeta });
|
||
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) {
|
||
if (res.reason === 'not-enough-budget') {
|
||
const mm = KlimaEngine.findMeasure(id);
|
||
showEvent('❗', 'Nicht genug Budget für ' + (mm ? mm.name : id), 'bad');
|
||
if (window.kwAudio) kwAudio.play('ui-error');
|
||
}
|
||
return false;
|
||
}
|
||
consumeEngineEvents(res.events);
|
||
consumeEngineAchievements(res.unlockedAchievements);
|
||
if (window.kwAudio) kwAudio.playForBuy(id);
|
||
syncMeasureMeshes();
|
||
renderUI();
|
||
autoSave();
|
||
|
||
// Gründach-Hinweis: jede Maßnahme hebt die Population-Obergrenze
|
||
// (engine.js computePopulationGrowth: capBonus = min(1, count * 0.05)),
|
||
// beim Gründach machen wir das didaktisch explizit — Wohnraum ist das
|
||
// Narrativ, das Schüler:innen dem Bauen am ehesten zuordnen.
|
||
if (id === 'green-roof') {
|
||
showEvent('🏡', 'Gründach fertig — neue Wohnungen frei. Einwohner:innen und Touristen können zuziehen, das bringt mehr Steuer-Einnahmen.', 'good', 'green_roof_info');
|
||
}
|
||
// Solar-Hinweis: Kauf-Feedback mit Strom-Leistung und Wartung, damit
|
||
// Schüler:innen die beiden Kennzahlen direkt mitnehmen.
|
||
if (id === 'solar') {
|
||
showEvent('☀️', 'Solaranlage fertig — +1 MW sauberer Strom. Wartung: 2 Mio €/Jahr.', 'good', 'solar_info');
|
||
}
|
||
|
||
// Ketten-Bau: Placement-Mode direkt mit gleicher Maßnahme fortsetzen,
|
||
// solange das Budget noch reicht. Der User beendet bewusst mit ESC /
|
||
// Rechtsklick bzw. wir brechen ab, wenn das Geld alle ist.
|
||
const m = KlimaEngine.findMeasure(id);
|
||
const nextCost = m ? Math.round(m.cost * state.researchDiscount) : 0;
|
||
if (state.budget >= nextCost) {
|
||
// Engine aktualisiert hat; Achievements-Animation soll trotzdem laufen.
|
||
// startPlacement rekursiv über onConfirm-Callback.
|
||
startPlacement(id, (pid, hp) => finalizeBuy(pid, hp));
|
||
} else {
|
||
showEvent('💰', 'Budget reicht nicht für weitere — Bau-Modus beendet', 'neutral');
|
||
}
|
||
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');
|
||
syncMeasureMeshes();
|
||
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() {
|
||
// Vor dem Tick: Bestand der Forest-Instanzen merken, damit wir nach dem
|
||
// Tick erkennen, wie viele Bäume von der Blackout-Logik gefällt wurden.
|
||
const prevForestCount = (state.ownedMeasures.forest && state.ownedMeasures.forest.count) || 0;
|
||
const res = KlimaEngine.tick(state);
|
||
const currForestCount = (state.ownedMeasures.forest && state.ownedMeasures.forest.count) || 0;
|
||
const felled = prevForestCount - currForestCount;
|
||
if (felled > 0) handleTreesFelled(felled, currForestCount);
|
||
|
||
// 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();
|
||
}
|
||
|
||
// Graph-Top = verloren: sobald CO₂ oder Temperatur die Obergrenze der
|
||
// Diagramme (700 ppm / 19 °C) erreicht, ist der Durchgang gekippt.
|
||
// Vor der Engine-endReason-Kette geprüft, damit Grenzwert-Verluste
|
||
// auch am MaxTick (würde sonst als 'won' zählen) noch greifen.
|
||
if (state.co2Ppm >= 700) {
|
||
state.levelWon = false; state.levelCompleted = true;
|
||
endLevel(false, 'co2-top');
|
||
} else if (state.currentTemp >= 19) {
|
||
state.levelWon = false; state.levelCompleted = true;
|
||
endLevel(false, 'temp-top');
|
||
} else if (res.endReason === 'won') endLevel(true);
|
||
else if (res.endReason === 'pleite') endLevel(false, 'pleite');
|
||
else if (res.endReason === 'ueberflutet') endLevel(false, 'ueberflutet');
|
||
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. */
|
||
// Merker für Edge-Detection (pro View-Instanz). Reset in startGame.
|
||
let _wasBudgetCritical = false;
|
||
let _wasTempCritical = false;
|
||
let _wasFloodCritical = false;
|
||
|
||
function updateCrisisMusic() {
|
||
if (!window.ggsMusicSetAuto) return;
|
||
const budgetCrit = state.budget < 0;
|
||
const tempCrit = state.currentTemp > 17;
|
||
const floodCrit = state.floodedPct > 30;
|
||
const critical = budgetCrit || tempCrit || floodCrit;
|
||
|
||
// Steigende Flanke Budget → Info-Karte erklärt das Umschalten der Musik
|
||
// und was jetzt zu tun ist. `infoKey: 'budget'` macht die Karte klickbar.
|
||
if (budgetCrit && !_wasBudgetCritical) {
|
||
showEvent('💸', 'Dein Budget ist ins Minus gerutscht — die Stadt macht Schulden. Reiss teure Wartungs-Maßnahmen ab oder baue zunächst nichts Neues. Die Musik wechselt auf Drama, bis du wieder im Plus bist.', 'bad', 'budget');
|
||
}
|
||
if (tempCrit && !_wasTempCritical) {
|
||
showEvent('🌡', 'Du hast +2 °C überschritten — das Pariser Klimaziel ist verfehlt. Viele Folgen sind jetzt unvermeidbar.', 'bad', 'temperature');
|
||
}
|
||
if (floodCrit && !_wasFloodCritical) {
|
||
showEvent('🌊', 'Über 30 % der Stadt sind überflutet — der Durchgang droht zu kippen. Jetzt dringend Küstenschutz aufstocken.', 'bad', 'sealevel');
|
||
}
|
||
_wasBudgetCritical = budgetCrit;
|
||
_wasTempCritical = tempCrit;
|
||
_wasFloodCritical = floodCrit;
|
||
|
||
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) => pickBuildPosition(id),
|
||
});
|
||
if (res.ok) {
|
||
consumeEngineEvents(res.events);
|
||
consumeEngineAchievements(res.unlockedAchievements);
|
||
}
|
||
state.pendingCitizenEvent = null;
|
||
state.phase = 'playing';
|
||
hideOverlay('citizen-overlay');
|
||
syncMeasureMeshes(); // citizen-youth verschenkt ein Solar-Panel
|
||
syncMountainShield(); // citizen-mountain setzt hasMountainShield
|
||
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.textContent = has ? a.icon : '?';
|
||
host.appendChild(el);
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
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 = '';
|
||
|
||
// „Bau beenden"-Karte oben, nur sichtbar während Placement-Mode. Niedrige
|
||
// Höhe + kontrastfarbener Hintergrund (coral), damit klar ist: das hier
|
||
// beendet den Bau, ist aber keine Maßnahme.
|
||
if (placementId) {
|
||
const exitCard = document.createElement('div');
|
||
exitCard.className = 'kw-exit-card';
|
||
exitCard.innerHTML = '<span class="kw-exit-icon">✖</span><span class="kw-exit-label">Bau beenden</span><span class="kw-exit-hint">ESC</span>';
|
||
exitCard.title = 'Platzierungs-Modus verlassen (oder ESC / Rechtsklick auf die Insel)';
|
||
exitCard.addEventListener('click', () => cancelPlacement());
|
||
host.appendChild(exitCard);
|
||
}
|
||
|
||
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>';
|
||
|
||
card.addEventListener('click', (e) => {
|
||
if (e.target.closest('[data-demolish]')) return;
|
||
if (!canAfford) { showEvent('💸', 'Zu teuer — spare Budget oder baue weniger.', 'bad'); return; }
|
||
buyMeasure(m.id);
|
||
});
|
||
host.appendChild(card);
|
||
});
|
||
|
||
$$('[data-demolish]').forEach(b => {
|
||
b.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
demolishMeasure(b.dataset.demolish);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 (< 30 %)', pct: Math.max(0, 100 - state.floodedPct * 3.33), done: state.floodedPct < 30 && 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();
|
||
if (typeof updatePauseCenter === 'function') updatePauseCenter();
|
||
// Szene wird aus der Frame-Loop gezeichnet (sceneRender), hier nicht
|
||
// nötig — anders als im 2D-Canvas-Renderer, der jedes State-Update
|
||
// synchron neu zeichnen musste.
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
3D SCENE (Three.js)
|
||
------------------------------------------------------------
|
||
Welt-Koordinaten: Insel als Ellipse 26×34 Einheiten in der XZ-
|
||
Ebene, Keil von hinten (−Z, Vulkan) nach vorn (+Z, Wasserlinie).
|
||
Ozean y=0 (steigt mit state.seaLevelCm), Vulkan-Kuppel bei y≈5.
|
||
Kamera-Orbit über Kugelkoordinaten (theta/phi/radius), Auto-
|
||
Drohnen-Drift wenn idle.
|
||
|
||
Terrain-Konstanten und -Funktionen sind aus V1-3D-TS portiert
|
||
(App/src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts), nicht
|
||
importiert — V1 bleibt für die Live-Instanz unangetastet.
|
||
============================================================ */
|
||
|
||
// --- Insel-Geometrie-Konstanten ---
|
||
const ISLAND_HALF_WIDTH = 13;
|
||
const ISLAND_HALF_LENGTH = 17;
|
||
const ISLAND_HIGH_EDGE_Y = 1.9; // Höhe am Vulkan-Rand (hinten)
|
||
const ISLAND_LOW_EDGE_Y = -0.25; // Vorderkante unter Wasser
|
||
const WATER_Y_BASE = 0.0;
|
||
const MAX_SEA_RISE_UNITS = 1.6; // 200 cm Meeresspiegel → 1.6 Welt-Einheiten
|
||
const VOLCANO_X = -3.0;
|
||
const VOLCANO_Z = -11.0;
|
||
const VOLCANO_BASE_RADIUS = 5.5;
|
||
const VOLCANO_BUMP_HEIGHT = 3.6;
|
||
const VOLCANO_DOME_HEIGHT = 2.1;
|
||
const VOLCANO_DOME_RADIUS = 2.2;
|
||
const VOLCANO_DOME_SINK = 0.45;
|
||
const ZONE_BEACH_END = 0.38;
|
||
const ZONE_MEADOW_END = 0.95;
|
||
const ZONE_FOREST_END = 1.80;
|
||
|
||
function smoothstep(e0, e1, x) {
|
||
const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)));
|
||
return t * t * (3 - 2 * t);
|
||
}
|
||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||
|
||
/**
|
||
* terrainHeight(x, z) — Höhe der Insel am Punkt (x, z).
|
||
* Ellipsen-Falloff × Keil × Vulkan-Gauss + sanfte Hügel.
|
||
* Außerhalb der Insel: negative Werte (unter dem Ozean).
|
||
*/
|
||
function terrainHeight(x, z) {
|
||
const ex = x / ISLAND_HALF_WIDTH;
|
||
const ez = z / ISLAND_HALF_LENGTH;
|
||
const edge = Math.sqrt(ex * ex + ez * ez);
|
||
const falloff = 1 - smoothstep(0.78, 1.08, edge);
|
||
const wedgeT = (z + ISLAND_HALF_LENGTH) / (ISLAND_HALF_LENGTH * 2);
|
||
const wedge = ISLAND_HIGH_EDGE_Y * (1 - wedgeT) + ISLAND_LOW_EDGE_Y * wedgeT;
|
||
const dvx = x - VOLCANO_X;
|
||
const dvz = z - VOLCANO_Z;
|
||
const vDist2 = dvx * dvx + dvz * dvz;
|
||
const volcanoBump = VOLCANO_BUMP_HEIGHT * Math.exp(
|
||
-vDist2 / (VOLCANO_BASE_RADIUS * VOLCANO_BASE_RADIUS)
|
||
);
|
||
const noise =
|
||
Math.sin(x * 0.35) * 0.14
|
||
+ Math.cos(z * 0.28) * 0.11
|
||
+ Math.sin(x * 0.22 + z * 0.18) * 0.09;
|
||
const land = (wedge + volcanoBump + noise) * falloff;
|
||
return land - (1 - falloff) * 2.5;
|
||
}
|
||
|
||
/** zoneColor(y) — Vertex-Farbe nach Höhe: Unterwasser → Strand → Wiese → Wald → Fels. */
|
||
function zoneColor(y) {
|
||
if (y < 0) return [0.25, 0.35, 0.38];
|
||
if (y < ZONE_BEACH_END) return [0.94, 0.87, 0.67];
|
||
if (y < ZONE_MEADOW_END) {
|
||
const t = (y - ZONE_BEACH_END) / (ZONE_MEADOW_END - ZONE_BEACH_END);
|
||
return [lerp(0.82, 0.52, t), lerp(0.82, 0.72, t), lerp(0.55, 0.38, t)];
|
||
}
|
||
if (y < ZONE_FOREST_END) {
|
||
const t = (y - ZONE_MEADOW_END) / (ZONE_FOREST_END - ZONE_MEADOW_END);
|
||
return [lerp(0.52, 0.38, t), lerp(0.72, 0.56, t), lerp(0.38, 0.30, t)];
|
||
}
|
||
const t = Math.min(1, (y - ZONE_FOREST_END) / 1.5);
|
||
return [lerp(0.42, 0.48, t), lerp(0.38, 0.35, t), lerp(0.32, 0.30, t)];
|
||
}
|
||
|
||
// --- Scene, Renderer, Camera ---
|
||
const sceneContainer = $('#scene-container');
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0xbcd5e6);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||
renderer.setSize(sceneContainer.clientWidth, sceneContainer.clientHeight, false);
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.localClippingEnabled = true; // für Gletscher-Schmelz-Clip
|
||
sceneContainer.appendChild(renderer.domElement);
|
||
renderer.domElement.style.cursor = 'grab';
|
||
renderer.domElement.style.touchAction = 'none';
|
||
|
||
const camera = new THREE.PerspectiveCamera(
|
||
42,
|
||
sceneContainer.clientWidth / Math.max(1, sceneContainer.clientHeight),
|
||
0.1,
|
||
500
|
||
);
|
||
|
||
// Kamera-Orbit: Kugelkoordinaten, später von Maus/Pfeiltasten/Drohne geändert.
|
||
let camTheta = Math.PI * 0.12; // Azimut (horizontale Drehung)
|
||
let camPhi = Math.PI * 0.34; // Polar (Höhe; π/2 = Horizont, 0 = Vogelperspektive)
|
||
let camRadius = 30; // Distanz vom Zentrum
|
||
let droneActive = true;
|
||
let droneResumeAt = 0; // in Sekunden; nach pauseDrone()+Idle weiter
|
||
let isDragging = false;
|
||
let lastPointerX = 0, lastPointerY = 0;
|
||
|
||
function applyCamera() {
|
||
const sinPhi = Math.sin(camPhi);
|
||
const cosPhi = Math.cos(camPhi);
|
||
camera.position.set(
|
||
Math.sin(camTheta) * sinPhi * camRadius,
|
||
cosPhi * camRadius,
|
||
Math.cos(camTheta) * sinPhi * camRadius
|
||
);
|
||
camera.lookAt(0, 0.5, 0);
|
||
}
|
||
function pauseDrone() {
|
||
droneActive = false;
|
||
droneResumeAt = (state.animMs || 0) / 1000 + 4; // 4 s nach letzter Aktivität
|
||
}
|
||
|
||
// --- Licht ---
|
||
const ambient = new THREE.AmbientLight(0xfff0d8, 0.7);
|
||
scene.add(ambient);
|
||
const sun = new THREE.DirectionalLight(0xffe8c0, 1.1);
|
||
sun.position.set(20, 28, 12);
|
||
scene.add(sun);
|
||
|
||
// --- Himmel-Dom (Halbkugel innen-eingefärbt) ---
|
||
const sky = new THREE.Mesh(
|
||
new THREE.SphereGeometry(300, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2),
|
||
new THREE.MeshBasicMaterial({ color: 0xbcd5e6, side: THREE.BackSide })
|
||
);
|
||
scene.add(sky);
|
||
const sunSphere = new THREE.Mesh(
|
||
new THREE.SphereGeometry(2.5, 24, 16),
|
||
new THREE.MeshBasicMaterial({ color: 0xffe4a0 })
|
||
);
|
||
sunSphere.position.set(28, 36, -18);
|
||
scene.add(sunSphere);
|
||
|
||
// --- Ozean ---
|
||
const oceanGeo = new THREE.PlaneGeometry(400, 400, 1, 1);
|
||
const oceanMat = new THREE.MeshStandardMaterial({ color: 0x2a78b8, roughness: 0.55, metalness: 0.15 });
|
||
const ocean = new THREE.Mesh(oceanGeo, oceanMat);
|
||
ocean.rotation.x = -Math.PI / 2;
|
||
ocean.position.y = WATER_Y_BASE;
|
||
ocean.userData.infoKey = 'ocean_info';
|
||
scene.add(ocean);
|
||
|
||
// --- Insel-Terrain als Höhenfeld mit Vertex-Farben ---
|
||
const islandGeo = new THREE.PlaneGeometry(
|
||
ISLAND_HALF_WIDTH * 2.4, ISLAND_HALF_LENGTH * 2.4, 90, 120
|
||
);
|
||
islandGeo.rotateX(-Math.PI / 2);
|
||
{
|
||
const pos = islandGeo.attributes.position;
|
||
const colorArr = new Float32Array(pos.count * 3);
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i);
|
||
const z = pos.getZ(i);
|
||
const y = terrainHeight(x, z);
|
||
pos.setY(i, y);
|
||
const c = zoneColor(y);
|
||
colorArr[i * 3] = c[0];
|
||
colorArr[i * 3 + 1] = c[1];
|
||
colorArr[i * 3 + 2] = c[2];
|
||
}
|
||
islandGeo.setAttribute('color', new THREE.BufferAttribute(colorArr, 3));
|
||
islandGeo.computeVertexNormals();
|
||
}
|
||
const island = new THREE.Mesh(
|
||
islandGeo,
|
||
new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1 })
|
||
);
|
||
island.userData.infoKey = 'island_info';
|
||
scene.add(island);
|
||
|
||
// --- Zone-Overlay: zeigt zur Platzierung die gültigen Bereiche als
|
||
// translucent-grüne Schicht. RGBA-Vertex-Farben via ShaderMaterial,
|
||
// weil MeshBasicMaterial/MeshStandardMaterial kein per-Vertex-Alpha
|
||
// unterstützen. Invalid-Vertices bekommen alpha=0 und werden discarded. ---
|
||
const zoneOverlayGeo = islandGeo.clone();
|
||
{
|
||
const n = zoneOverlayGeo.attributes.position.count;
|
||
zoneOverlayGeo.setAttribute('color4', new THREE.BufferAttribute(new Float32Array(n * 4), 4));
|
||
}
|
||
const zoneOverlayMat = new THREE.ShaderMaterial({
|
||
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
||
vertexShader:
|
||
'attribute vec4 color4;\n' +
|
||
'varying vec4 vColor;\n' +
|
||
'void main() {\n' +
|
||
' vColor = color4;\n' +
|
||
' gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n' +
|
||
'}',
|
||
fragmentShader:
|
||
'varying vec4 vColor;\n' +
|
||
'void main() {\n' +
|
||
' if (vColor.a < 0.01) discard;\n' +
|
||
' gl_FragColor = vColor;\n' +
|
||
'}',
|
||
});
|
||
const zoneOverlay = new THREE.Mesh(zoneOverlayGeo, zoneOverlayMat);
|
||
zoneOverlay.position.y = 0.04; // leicht über dem Terrain, verhindert Z-Fighting
|
||
zoneOverlay.visible = false;
|
||
scene.add(zoneOverlay);
|
||
|
||
// --- Vulkan-Kuppel (Lathe-Kurve → Glockenform) mit Schnee-Kappe + Schmelz-Clip ---
|
||
const volcanoGroup = new THREE.Group();
|
||
{
|
||
const terrainPeakY = terrainHeight(VOLCANO_X, VOLCANO_Z);
|
||
const domeBaseY = terrainPeakY - VOLCANO_DOME_SINK;
|
||
|
||
const domePoints = [];
|
||
for (let i = 0; i <= 14; i++) {
|
||
const t = i / 14;
|
||
const r = VOLCANO_DOME_RADIUS * Math.cos(t * Math.PI / 2);
|
||
const y = t * VOLCANO_DOME_HEIGHT;
|
||
domePoints.push(new THREE.Vector2(r, y));
|
||
}
|
||
const domeGeo = new THREE.LatheGeometry(domePoints, 28);
|
||
const domePos = domeGeo.attributes.position;
|
||
for (let i = 0; i < domePos.count; i++) {
|
||
const x = domePos.getX(i), y = domePos.getY(i), z = domePos.getZ(i);
|
||
const noise = Math.sin(x * 1.8 + y * 2.3) * 0.05 + Math.cos(z * 2.1 - y * 1.4) * 0.04;
|
||
domePos.setX(i, x + noise);
|
||
domePos.setZ(i, z + noise);
|
||
}
|
||
domeGeo.computeVertexNormals();
|
||
|
||
const volcanoRock = new THREE.Mesh(
|
||
domeGeo,
|
||
new THREE.MeshStandardMaterial({ color: 0x6b5a4a, roughness: 0.95 })
|
||
);
|
||
volcanoRock.position.set(VOLCANO_X, domeBaseY, VOLCANO_Z);
|
||
volcanoGroup.add(volcanoRock);
|
||
|
||
// Schnee-Kappe: Klon, leicht aufgeblasen und weiß, mit Clipping-Ebene
|
||
// für die Schmelze (wandert mit state.currentTemp hoch).
|
||
const snowGeo = domeGeo.clone();
|
||
const snowPos = snowGeo.attributes.position;
|
||
for (let i = 0; i < snowPos.count; i++) {
|
||
const x = snowPos.getX(i), y = snowPos.getY(i), z = snowPos.getZ(i);
|
||
const len = Math.sqrt(x * x + y * y + z * z) || 1;
|
||
snowPos.setX(i, x + (x / len) * 0.03);
|
||
snowPos.setY(i, y + 0.03);
|
||
snowPos.setZ(i, z + (z / len) * 0.03);
|
||
}
|
||
snowGeo.computeVertexNormals();
|
||
const snowClipPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 1000);
|
||
const snowMat = new THREE.MeshStandardMaterial({
|
||
color: 0xffffff, roughness: 0.75,
|
||
clippingPlanes: [snowClipPlane], clipShadows: true,
|
||
});
|
||
const glacier = new THREE.Mesh(snowGeo, snowMat);
|
||
glacier.position.copy(volcanoRock.position);
|
||
volcanoGroup.add(glacier);
|
||
// Welt-Y der Schnee-Basis merken — Clip-Ebene wandert relativ dazu
|
||
volcanoGroup.userData.snowBaseY = glacier.position.y;
|
||
volcanoGroup.userData.snowClipPlane = snowClipPlane;
|
||
}
|
||
volcanoGroup.userData.infoKey = 'volcano_info';
|
||
scene.add(volcanoGroup);
|
||
|
||
// --- Resize ---
|
||
function onSceneResize() {
|
||
const w = sceneContainer.clientWidth;
|
||
const h = sceneContainer.clientHeight;
|
||
if (w <= 0 || h <= 0) return;
|
||
renderer.setSize(w, h, false);
|
||
camera.aspect = w / Math.max(1, h);
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', onSceneResize);
|
||
onSceneResize();
|
||
|
||
// --- Maus-Steuerung: Drag rotiert Kamera, Scrollrad zoomt ---
|
||
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||
// Experten-Modus: Linksklick → expertClickAt (kein Drag)
|
||
if (expertMode) {
|
||
if (e.button === 0) expertClickAt(e.clientX, e.clientY);
|
||
else if (e.button === 2) exitExpertMode();
|
||
return;
|
||
}
|
||
// Placement-Mode: Linksklick = bestätigen (wenn valide), Rechtsklick = abbrechen.
|
||
if (placementId) {
|
||
if (e.button === 0) { updatePlacementGhost(e.clientX, e.clientY); confirmPlacement(); }
|
||
else if (e.button === 2) cancelPlacement();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
isDragging = true;
|
||
lastPointerX = e.clientX;
|
||
lastPointerY = e.clientY;
|
||
renderer.domElement.style.cursor = 'grabbing';
|
||
pauseDrone();
|
||
});
|
||
window.addEventListener('pointermove', (e) => {
|
||
if (placementId) return; // Ghost-Update läuft im Placement-Listener
|
||
if (!isDragging) return;
|
||
const dx = e.clientX - lastPointerX;
|
||
const dy = e.clientY - lastPointerY;
|
||
lastPointerX = e.clientX;
|
||
lastPointerY = e.clientY;
|
||
camTheta -= dx * 0.005;
|
||
camPhi = Math.max(0.10, Math.min(Math.PI * 0.48, camPhi - dy * 0.005));
|
||
});
|
||
window.addEventListener('pointerup', () => {
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
renderer.domElement.style.cursor = 'grab';
|
||
pauseDrone();
|
||
});
|
||
renderer.domElement.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
camRadius = Math.max(14, Math.min(70, camRadius + e.deltaY * 0.02));
|
||
pauseDrone();
|
||
}, { passive: false });
|
||
|
||
// --- Tastatur: Pfeiltasten drehen manuell (Drohne pausiert dann) ---
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
||
const step = 0.08;
|
||
if (e.key === 'ArrowLeft') { camTheta -= step; pauseDrone(); }
|
||
if (e.key === 'ArrowRight') { camTheta += step; pauseDrone(); }
|
||
if (e.key === 'ArrowUp') { camPhi = Math.max(0.10, camPhi - step * 0.6); pauseDrone(); }
|
||
if (e.key === 'ArrowDown') { camPhi = Math.min(Math.PI * 0.48, camPhi + step * 0.6); pauseDrone(); }
|
||
});
|
||
|
||
// sceneRender wird pro Frame aus der TICK-LOOP gerufen. Pause friert alles
|
||
// ein, weil Drohnen-Drift, Windrad-Rotation und Meeresspiegel-Updates an
|
||
// state.animMs hängen (und das steht bei pause still).
|
||
let _lastSceneMs = 0;
|
||
function sceneRender() {
|
||
const tNow = (state.animMs || 0) / 1000;
|
||
const dt = Math.max(0, tNow - _lastSceneMs);
|
||
_lastSceneMs = tNow;
|
||
|
||
// Auto-Drohne: nach Idle-Timeout wieder sanft weiter-drehen
|
||
if (!droneActive && tNow >= droneResumeAt && !isDragging) droneActive = true;
|
||
if (droneActive) camTheta += dt * 0.06;
|
||
|
||
// Meeresspiegel folgt state.seaLevelCm (0 cm = WATER_Y_BASE, 200 cm = +MAX_SEA_RISE)
|
||
const sea = Math.max(0, state.seaLevelCm || 0);
|
||
ocean.position.y = WATER_Y_BASE + (sea / 200) * MAX_SEA_RISE_UNITS;
|
||
|
||
// Gletscher-Schmelze: 15 °C = voll, 18 °C = weg. Clip-Ebene wandert hoch.
|
||
const clip = volcanoGroup.userData.snowClipPlane;
|
||
const snowBase = volcanoGroup.userData.snowBaseY || 0;
|
||
const temp = state.currentTemp || 15;
|
||
const snowFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3));
|
||
const meltY = snowBase + (1 - snowFraction) * (VOLCANO_DOME_HEIGHT + 0.3);
|
||
clip.constant = -meltY;
|
||
|
||
// Windräder drehen — alle rotorRefs aus placedMeshes durchlaufen.
|
||
// Rotor.z wächst mit tNow, sodass Pause auch Rotoren stoppt.
|
||
for (const mesh of placedMeshes.values()) {
|
||
const rotor = mesh.userData && mesh.userData.rotorRef;
|
||
if (rotor) rotor.rotation.z = tNow * 1.4;
|
||
}
|
||
|
||
// Flut-Tönung: sobald ein Gebäude unter Wasser gerät, Materialien
|
||
// Richtung dunkelgrau/schwarz lerpen + leicht nach hinten kippen
|
||
// ("defekt"-Look). Gilt für Maßnahmen, Dorf-Häuser und Citizen-Strand.
|
||
applyFloodTintAll();
|
||
|
||
// Mangroven-Gesundheit: ab 30 cm Meeresspiegel fangen sie an zu sterben,
|
||
// bei 70 cm sind sie komplett grau. Standard-Farben werden Richtung
|
||
// grau/dunkelbraun interpoliert — Versalzung + Überflutung sichtbar.
|
||
{
|
||
const death = Math.max(0, Math.min(1, (state.seaLevelCm - 30) / 40));
|
||
if (death > 0) {
|
||
for (const [key, mesh] of placedMeshes) {
|
||
if (!key.startsWith('mangrove-')) continue;
|
||
const m = mesh.userData && mesh.userData.mangroveMats;
|
||
if (!m) continue;
|
||
// Stamm: Dunkelbraun → fast schwarz
|
||
m.trunk.color.setRGB(
|
||
lerp(0x4a/255, 0x2a/255, death),
|
||
lerp(0x30/255, 0x20/255, death),
|
||
lerp(0x18/255, 0x14/255, death)
|
||
);
|
||
// Blätter: Grün → Grau-Braun
|
||
m.leaf.color.setRGB(
|
||
lerp(0x2a/255, 0x5a/255, death),
|
||
lerp(0x6a/255, 0x58/255, death),
|
||
lerp(0x3a/255, 0x4a/255, death)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Auch der Ghost eines Windrads soll schon drehen
|
||
if (placementGhost) {
|
||
const rotor = placementGhost.userData && placementGhost.userData.rotorRef;
|
||
if (rotor) rotor.rotation.z = tNow * 1.4;
|
||
}
|
||
|
||
// Boote, Helikopter, Dorf-Rauch — einheitliche Zeitbasis (Pause friert ein)
|
||
updateAmbient(tNow, dt);
|
||
|
||
applyCamera();
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
/* ============================================================
|
||
MASSNAHMEN — PLATZIERUNG (View-seitiger Hook für Engine)
|
||
------------------------------------------------------------
|
||
Engine ruft resolveMeta(id) beim Kauf/Bürger-Event, wir liefern
|
||
{ pos:{x,y,z}, rotation, zoneId }. Phase A nutzt einen einfachen
|
||
Random-Wurf innerhalb Zone-Radien — reicht, damit Engine alle
|
||
Instanz-Positionen bekommt. Phase D ersetzt das durch Raycaster
|
||
+ Placement-Ghost + manuelle Klick-Platzierung.
|
||
============================================================ */
|
||
|
||
// Zonen skaliert auf die Terrain-Ellipse (halbe Breite 13, halbe Länge 17):
|
||
// `land` = Inselinneres (Wiese/Wald, min. Höhe 0.3), `edge` = Küstenrand
|
||
// knapp über Wasser, `sea` = offenes Meer vor der Küste.
|
||
const MEASURE_ZONES_3D = {
|
||
'cloud-seed': { kind: 'sea', rMin: 20, rMax: 28 },
|
||
'wind': { kind: 'land', minH: 0.9, maxH: 1.6 },
|
||
'coal': { kind: 'land', minH: 0.5, maxH: 1.2 },
|
||
'airport': { kind: 'land', minH: 0.4, maxH: 1.0 },
|
||
'forest': { kind: 'land', minH: 0.5, maxH: 1.8 },
|
||
'solar': { kind: 'land', minH: 0.5, maxH: 1.4 },
|
||
'green-roof': { kind: 'land', minH: 0.4, maxH: 1.2 },
|
||
'bikes': { kind: 'coastband' }, // direkt am Strand, Übergang Wiese/Sand
|
||
'mangrove': { kind: 'edge' },
|
||
'sand-fill': { kind: 'edge' },
|
||
'dike': { kind: 'edge' },
|
||
'sea-wall': { kind: 'edge' },
|
||
};
|
||
|
||
/**
|
||
* Wählt eine zufällige, zonen-gültige Welt-Position für eine Maßnahme.
|
||
* Versucht bis zu 30 Wiesen-Stichproben; wenn keine passende Höhe
|
||
* gefunden wird, Fallback auf Insel-Zentrum. Deterministisch ausreichend
|
||
* für Instanz-Verteilung (Phase D ersetzt das durch Klick-Platzierung).
|
||
*/
|
||
function pickBuildPosition(id) {
|
||
const zone = MEASURE_ZONES_3D[id] || { kind: 'land', minH: 0.5, maxH: 1.4 };
|
||
let x = 0, z = 0, y = 0;
|
||
if (zone.kind === 'coastband') {
|
||
// Küsten-Band: auf dem Ellipsen-Ring bei 0.75..0.85, Höhe zwischen
|
||
// Strand und Wiese — Radwege laufen direkt am Strand entlang.
|
||
for (let i = 0; i < 40; i++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = 0.75 + Math.random() * 0.10;
|
||
x = Math.cos(a) * ISLAND_HALF_WIDTH * r;
|
||
z = Math.sin(a) * ISLAND_HALF_LENGTH * r;
|
||
y = terrainHeight(x, z);
|
||
if (y >= 0.32 && y <= 0.62) break;
|
||
}
|
||
return {
|
||
pos: { x: +x.toFixed(2), y: +y.toFixed(2), z: +z.toFixed(2) },
|
||
rotation: +coastRotation(x, z).toFixed(3),
|
||
zoneId: zone.kind,
|
||
};
|
||
}
|
||
if (zone.kind === 'sea') {
|
||
// Offenes Meer in einem Ring rund um die Insel
|
||
const r = (zone.rMin || 20) + Math.random() * ((zone.rMax || 28) - (zone.rMin || 20));
|
||
const a = Math.random() * Math.PI * 2;
|
||
x = Math.cos(a) * r;
|
||
z = Math.sin(a) * r;
|
||
y = WATER_Y_BASE + 0.2;
|
||
} else if (zone.kind === 'edge') {
|
||
// Küstenring: Ellipsenrand bei edge ≈ 0.85 (smoothstep-Falloff 0.78..1.08)
|
||
for (let i = 0; i < 40; i++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const rx = ISLAND_HALF_WIDTH * 0.88;
|
||
const ry = ISLAND_HALF_LENGTH * 0.88;
|
||
x = Math.cos(a) * rx;
|
||
z = Math.sin(a) * ry;
|
||
y = terrainHeight(x, z);
|
||
if (y > -0.3 && y < 0.35) break;
|
||
}
|
||
} else {
|
||
// Landfläche: Ellipsen-Stichproben bis Höhe im gewünschten Fenster
|
||
const minH = zone.minH || 0.4, maxH = zone.maxH || 1.6;
|
||
let found = false;
|
||
for (let i = 0; i < 30; i++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const r = Math.sqrt(Math.random()) * 0.75; // gleichmäßig verteilt im Kern
|
||
x = Math.cos(a) * ISLAND_HALF_WIDTH * r;
|
||
z = Math.sin(a) * ISLAND_HALF_LENGTH * r;
|
||
y = terrainHeight(x, z);
|
||
if (y >= minH && y <= maxH) { found = true; break; }
|
||
}
|
||
if (!found) { x = 0; z = 0; y = terrainHeight(0, 0); }
|
||
}
|
||
return {
|
||
pos: { x: +x.toFixed(2), y: +y.toFixed(2), z: +z.toFixed(2) },
|
||
rotation: +((Math.random() * Math.PI * 2).toFixed(3)),
|
||
zoneId: zone.kind,
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
MASSNAHMEN — MESH-FACTORIES
|
||
------------------------------------------------------------
|
||
Jede Factory gibt eine THREE.Group zurück mit lokalem Koordinaten-
|
||
ursprung am Boden-Ankerpunkt. `createMeasureMesh(id)` ist der
|
||
zentrale Dispatcher. Modelle sind in Vanilla JS portiert aus
|
||
V1-3D (sim-05-treibhaus-3d/game-renderer-3d.ts) — 1:1 Geometrie,
|
||
nur TypeScript-Klassen-Syntax ist weg.
|
||
============================================================ */
|
||
|
||
function makeTree() {
|
||
const g = new THREE.Group();
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.08, 0.12, 0.5, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a3a22, roughness: 1 })
|
||
);
|
||
trunk.position.y = 0.25; g.add(trunk);
|
||
const leaves = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.35, 0.85, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x3a7a4a, roughness: 1 })
|
||
);
|
||
leaves.position.y = 0.9; g.add(leaves);
|
||
const leaves2 = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.28, 0.6, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x4a8a5a, roughness: 1 })
|
||
);
|
||
leaves2.position.y = 1.2; g.add(leaves2);
|
||
return g;
|
||
}
|
||
|
||
function makeSolarPanel() {
|
||
const g = new THREE.Group();
|
||
const stand = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.04, 0.35, 0.04),
|
||
new THREE.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 1 })
|
||
);
|
||
stand.position.y = 0.18; g.add(stand);
|
||
const panel = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.8, 0.04, 0.5),
|
||
new THREE.MeshStandardMaterial({ color: 0x1e3a5a, roughness: 0.4, metalness: 0.3 })
|
||
);
|
||
panel.position.y = 0.38; panel.rotation.x = -Math.PI / 8; g.add(panel);
|
||
return g;
|
||
}
|
||
|
||
function makeWindTurbine() {
|
||
const g = new THREE.Group();
|
||
const mast = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.05, 0.08, 1.8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 0.6 })
|
||
);
|
||
mast.position.y = 0.9; g.add(mast);
|
||
const hub = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.08, 12, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xdddddd })
|
||
);
|
||
hub.position.y = 1.8; g.add(hub);
|
||
const rotor = new THREE.Group();
|
||
rotor.position.y = 1.8;
|
||
for (let i = 0; i < 3; i++) {
|
||
const blade = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.05, 0.7, 0.03),
|
||
new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.8 })
|
||
);
|
||
blade.geometry.translate(0, 0.35, 0);
|
||
blade.rotation.z = (i * Math.PI * 2) / 3;
|
||
rotor.add(blade);
|
||
}
|
||
g.add(rotor);
|
||
g.userData.rotorRef = rotor; // wird in sceneRender animiert
|
||
return g;
|
||
}
|
||
|
||
function makeGreenRoof() {
|
||
const g = new THREE.Group();
|
||
const house = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.7, 0.5, 0.7),
|
||
new THREE.MeshStandardMaterial({ color: 0xd8cfbe, roughness: 1 })
|
||
);
|
||
house.position.y = 0.25; g.add(house);
|
||
const roof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.8, 0.08, 0.8),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a8a5a, roughness: 1 })
|
||
);
|
||
roof.position.y = 0.54; g.add(roof);
|
||
return g;
|
||
}
|
||
|
||
function makeDike() {
|
||
const g = new THREE.Group();
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.0, 0.22, 0.6),
|
||
new THREE.MeshStandardMaterial({ color: 0x8a6f4a, roughness: 1 })
|
||
);
|
||
body.position.y = 0.11; g.add(body);
|
||
const crown = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.0, 0.04, 0.35),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a8a4a, roughness: 1 })
|
||
);
|
||
crown.position.y = 0.24; g.add(crown);
|
||
return g;
|
||
}
|
||
|
||
function makeSeaWall() {
|
||
const g = new THREE.Group();
|
||
const wall = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.6, 1.25, 0.30),
|
||
new THREE.MeshStandardMaterial({ color: 0xb0b3b5, roughness: 0.95 })
|
||
);
|
||
wall.position.y = 0.62; g.add(wall);
|
||
const cap = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.62, 0.08, 0.34),
|
||
new THREE.MeshStandardMaterial({ color: 0x70757a, roughness: 1 })
|
||
);
|
||
cap.position.y = 1.28; g.add(cap);
|
||
return g;
|
||
}
|
||
|
||
function makeCoalPlant() {
|
||
const g = new THREE.Group();
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.95, 0.55, 0.85),
|
||
new THREE.MeshStandardMaterial({ color: 0x4a4a52, roughness: 1 })
|
||
);
|
||
body.position.y = 0.28; g.add(body);
|
||
for (const sx of [-0.22, 0.22]) {
|
||
const stack = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.09, 0.10, 0.95, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a5660, roughness: 1 })
|
||
);
|
||
stack.position.set(sx, 0.78, -0.18); g.add(stack);
|
||
const ring = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.105, 0.105, 0.10, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xa04a3a, roughness: 1 })
|
||
);
|
||
ring.position.set(sx, 1.20, -0.18); g.add(ring);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function makeAirport() {
|
||
const g = new THREE.Group();
|
||
// Plateau-Sockel: hebt den Flughafen sichtbar aus dem Terrain und
|
||
// verhindert, dass Teile am Hang im Berg verschwinden. Länge bleibt
|
||
// knapp innerhalb der Runway (2.4), Z-Richtung ist nach +Z versetzt,
|
||
// um Terminal + Heli-Pad abzudecken (sitzen beide bei z≈0.55).
|
||
const plateauBase = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.55, 0.20, 1.25),
|
||
new THREE.MeshStandardMaterial({ color: 0x50555a, roughness: 1 })
|
||
);
|
||
plateauBase.position.set(0, 0.10, 0.28); g.add(plateauBase);
|
||
const plateauTop = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.48, 0.04, 1.18),
|
||
new THREE.MeshStandardMaterial({ color: 0x787d82, roughness: 1 })
|
||
);
|
||
plateauTop.position.set(0, 0.22, 0.28); g.add(plateauTop);
|
||
// Alle Airport-Strukturen um LIFT anheben (sitzen jetzt auf der Plateau-Kante)
|
||
const LIFT = 0.24;
|
||
|
||
const runway = new THREE.Mesh(
|
||
new THREE.BoxGeometry(2.4, 0.04, 0.45),
|
||
new THREE.MeshStandardMaterial({ color: 0x404048, roughness: 1 })
|
||
);
|
||
runway.position.y = 0.02 + LIFT; g.add(runway);
|
||
for (let i = -2; i <= 2; i++) {
|
||
const stripe = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.18, 0.002, 0.05),
|
||
new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 1 })
|
||
);
|
||
stripe.position.set(i * 0.4, 0.045 + LIFT, 0); g.add(stripe);
|
||
}
|
||
const terminal = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.8, 0.32, 0.45),
|
||
new THREE.MeshStandardMaterial({ color: 0xe4e0d4, roughness: 1 })
|
||
);
|
||
terminal.position.set(-0.6, 0.18 + LIFT, 0.55); g.add(terminal);
|
||
const tower = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.07, 0.08, 0.55, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xd0c8b4, roughness: 1 })
|
||
);
|
||
tower.position.set(-0.95, 0.43 + LIFT, 0.55); g.add(tower);
|
||
const towerTop = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.18, 0.10, 0.18),
|
||
new THREE.MeshStandardMaterial({ color: 0x2a4a6a, roughness: 0.6 })
|
||
);
|
||
towerTop.position.set(-0.95, 0.78 + LIFT, 0.55); g.add(towerTop);
|
||
// Heli-Pad: durchgehend schwarze Scheibe mit weißem H-Marking
|
||
const padBlack = new THREE.MeshStandardMaterial({ color: 0x000000, roughness: 1 });
|
||
const padWhite = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 });
|
||
const pad = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.02, 28), padBlack);
|
||
pad.position.set(0.85, 0.03 + LIFT, 0.55); g.add(pad);
|
||
const hBarL = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.006, 0.28), padWhite);
|
||
hBarL.position.set(0.73, 0.045 + LIFT, 0.55); g.add(hBarL);
|
||
const hBarR = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.006, 0.28), padWhite);
|
||
hBarR.position.set(0.97, 0.045 + LIFT, 0.55); g.add(hBarR);
|
||
const hBarM = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.006, 0.06), padWhite);
|
||
hBarM.position.set(0.85, 0.045 + LIFT, 0.55); g.add(hBarM);
|
||
// Heli-Offset inkl. LIFT, damit die Lande-Position korrekt ist
|
||
g.userData.heliPadLocal = { x: 0.85, y: 0.04 + LIFT, z: 0.55 };
|
||
g.scale.setScalar(2);
|
||
g.userData.isAirport = true;
|
||
return g;
|
||
}
|
||
|
||
function makeCloudSeeder() {
|
||
const g = new THREE.Group();
|
||
const base = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.4, 0.18, 0.4),
|
||
new THREE.MeshStandardMaterial({ color: 0x9098a0, roughness: 1 })
|
||
);
|
||
base.position.y = 0.09; g.add(base);
|
||
const mast = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.04, 0.05, 0.95, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xb0b8c0, roughness: 0.6 })
|
||
);
|
||
mast.position.y = 0.65; g.add(mast);
|
||
const dish = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.28, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2),
|
||
new THREE.MeshStandardMaterial({ color: 0xe8eef2, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide })
|
||
);
|
||
dish.position.y = 1.10; dish.rotation.x = -Math.PI / 4; g.add(dish);
|
||
return g;
|
||
}
|
||
|
||
function makeBikePath() {
|
||
const g = new THREE.Group();
|
||
const asphalt = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.4, 0.04, 0.45),
|
||
new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 1 })
|
||
);
|
||
asphalt.position.y = 0.02; g.add(asphalt);
|
||
for (let i = -2; i <= 2; i++) {
|
||
const stripe = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.18, 0.005, 0.04),
|
||
new THREE.MeshStandardMaterial({ color: 0xf2f2f2, roughness: 1 })
|
||
);
|
||
stripe.position.set(i * 0.28, 0.045, 0); g.add(stripe);
|
||
}
|
||
for (const sx of [-0.55, 0.55]) {
|
||
const post = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.02, 0.025, 0.22, 5),
|
||
new THREE.MeshStandardMaterial({ color: 0x6a8a4a, roughness: 1 })
|
||
);
|
||
post.position.set(sx, 0.13, 0.30); g.add(post);
|
||
const sign = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.13, 0.13, 0.02),
|
||
new THREE.MeshStandardMaterial({ color: 0x4a8a6a, roughness: 1 })
|
||
);
|
||
sign.position.set(sx, 0.30, 0.30); g.add(sign);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function makeMangrove() {
|
||
const g = new THREE.Group();
|
||
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3018, roughness: 1 });
|
||
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2a6a3a, roughness: 1 });
|
||
// Material-Refs im userData — updateMangroveHealth färbt sie später
|
||
// bei steigendem Meeresspiegel ins Graue.
|
||
g.userData.mangroveMats = { trunk: trunkMat, leaf: leafMat };
|
||
const positions = [[-0.25, -0.18], [0.22, 0.0], [-0.05, 0.25]];
|
||
for (const [px, pz] of positions) {
|
||
for (let i = 0; i < 3; i++) {
|
||
const a = (i / 3) * Math.PI * 2;
|
||
const root = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.018, 0.025, 0.32, 5),
|
||
trunkMat
|
||
);
|
||
root.position.set(px + Math.cos(a) * 0.06, 0.16, pz + Math.sin(a) * 0.06);
|
||
root.rotation.z = Math.cos(a) * 0.4;
|
||
root.rotation.x = Math.sin(a) * 0.4;
|
||
g.add(root);
|
||
}
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.05, 0.06, 0.30, 6),
|
||
trunkMat
|
||
);
|
||
trunk.position.set(px, 0.46, pz); g.add(trunk);
|
||
const leaves = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.25, 10, 8),
|
||
leafMat
|
||
);
|
||
leaves.position.set(px, 0.68, pz);
|
||
leaves.scale.set(1, 0.7, 1);
|
||
g.add(leaves);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
// Sand-Aufschüttung: glockenförmiger Sandhügel (Lathe, Cos-Profil). Ist
|
||
// bewusst klein und zurückhaltend — wirkt als lokaler Dünenabschnitt am
|
||
// Ufer und nicht als Podest. Parallel-Ausrichtung über coastRotation.
|
||
function makeSandFill() {
|
||
const g = new THREE.Group();
|
||
const points = [];
|
||
const segments = 12, radius = 0.85, height = 0.42;
|
||
for (let i = 0; i <= segments; i++) {
|
||
const t = i / segments;
|
||
const r = radius * Math.cos(t * Math.PI / 2);
|
||
points.push(new THREE.Vector2(r, t * height));
|
||
}
|
||
const geom = new THREE.LatheGeometry(points, 18);
|
||
const pos = geom.attributes.position;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
|
||
pos.setY(i, y + Math.sin(x * 5) * 0.015 + Math.cos(z * 4) * 0.012);
|
||
}
|
||
geom.computeVertexNormals();
|
||
const sand = new THREE.Mesh(
|
||
geom,
|
||
new THREE.MeshStandardMaterial({ color: 0xe6cf8a, roughness: 1 })
|
||
);
|
||
g.add(sand);
|
||
return g;
|
||
}
|
||
|
||
// Maßnahmen-ID → INFO_TOPICS-Key für den Experten-Modus
|
||
const MEASURE_INFO_KEY = {
|
||
'forest': 'forest_info',
|
||
'solar': 'solar_info',
|
||
'wind': 'wind_info',
|
||
'green-roof': 'green_roof_info',
|
||
'dike': 'dike_info',
|
||
'sea-wall': 'sea_wall_info',
|
||
'coal': 'coal_power',
|
||
'airport': 'aviation',
|
||
'cloud-seed': 'cloud_seeding',
|
||
'bikes': 'bikes_info',
|
||
'mangrove': 'mangrove_info',
|
||
'sand-fill': 'sand_fill_info',
|
||
};
|
||
|
||
/** Dispatcher — gibt eine neue Gruppe pro Aufruf (keine geteilte Geometrie).
|
||
* Setzt zusätzlich userData.infoKey, damit der Experten-Modus auf jedes
|
||
* Mesh reagieren kann. */
|
||
function createMeasureMesh(id) {
|
||
let mesh = null;
|
||
switch (id) {
|
||
case 'forest': mesh = makeTree(); break;
|
||
case 'solar': mesh = makeSolarPanel(); break;
|
||
case 'wind': mesh = makeWindTurbine(); break;
|
||
case 'green-roof': mesh = makeGreenRoof(); break;
|
||
case 'dike': mesh = makeDike(); break;
|
||
case 'sea-wall': mesh = makeSeaWall(); break;
|
||
case 'coal': mesh = makeCoalPlant(); break;
|
||
case 'airport': mesh = makeAirport(); break;
|
||
case 'cloud-seed': mesh = makeCloudSeeder(); break;
|
||
case 'bikes': mesh = makeBikePath(); break;
|
||
case 'mangrove': mesh = makeMangrove(); break;
|
||
case 'sand-fill': mesh = makeSandFill(); break;
|
||
}
|
||
if (mesh) mesh.userData.infoKey = MEASURE_INFO_KEY[id] || id;
|
||
return mesh;
|
||
}
|
||
|
||
/* ============================================================
|
||
MASSNAHMEN — SCENE-SYNC
|
||
------------------------------------------------------------
|
||
Hält state.ownedMeasures und die 3D-Meshes im Einklang: neue
|
||
Instanzen bekommen ein Mesh, abgerissene werden entfernt.
|
||
Jede Instanz hat ein stabiles Key `${id}-${instanceIdx}`.
|
||
============================================================ */
|
||
|
||
const measuresGroup = new THREE.Group();
|
||
scene.add(measuresGroup);
|
||
const placedMeshes = new Map(); // key → Object3D
|
||
|
||
function instanceKey(id, idx) { return id + '-' + idx; }
|
||
|
||
function syncMeasureMeshes() {
|
||
const seen = new Set();
|
||
for (const id in state.ownedMeasures) {
|
||
const owned = state.ownedMeasures[id];
|
||
if (!owned || !owned.instances) continue;
|
||
owned.instances.forEach((inst, idx) => {
|
||
const key = instanceKey(id, idx);
|
||
seen.add(key);
|
||
if (placedMeshes.has(key)) return;
|
||
// Neu aufstellen
|
||
const mesh = createMeasureMesh(id);
|
||
if (!mesh) return;
|
||
const p = inst.pos || { x: 0, y: 0, z: 0 };
|
||
mesh.position.set(p.x, p.y, p.z);
|
||
if (typeof inst.rotation === 'number') mesh.rotation.y = inst.rotation;
|
||
measuresGroup.add(mesh);
|
||
placedMeshes.set(key, mesh);
|
||
});
|
||
}
|
||
// Instanzen, die weg sind (Abriss), entfernen
|
||
for (const key of Array.from(placedMeshes.keys())) {
|
||
if (seen.has(key)) continue;
|
||
const mesh = placedMeshes.get(key);
|
||
measuresGroup.remove(mesh);
|
||
placedMeshes.delete(key);
|
||
}
|
||
// Airport-Fahrzeuge (Heli + Flugzeug) an geänderten Airport-Bestand
|
||
// anpassen — muss nach syncMeasureMeshes laufen, damit der Airport-Mesh
|
||
// schon in placedMeshes liegt.
|
||
if (typeof syncAirportVehicles === 'function') syncAirportVehicles();
|
||
}
|
||
|
||
/* ============================================================
|
||
MASSNAHMEN — PLACEMENT MODE (Raycaster + Ghost)
|
||
------------------------------------------------------------
|
||
Kauf einer Maßnahme startet den Placement-Mode: ein halb-
|
||
transparenter Ghost folgt dem Mauszeiger, grün wenn die
|
||
Ziel-Zone passt, rot sonst. Linksklick bestätigt (→ Engine-
|
||
Buy mit der Hit-Position), Rechtsklick oder ESC bricht ab.
|
||
============================================================ */
|
||
|
||
let placementId = null;
|
||
let placementGhost = null;
|
||
let placementGhostMats = [];
|
||
let placementOnConfirm = null;
|
||
let placementValid = false;
|
||
let placementRotation = 0; // wird bei Platzierung einmal festgelegt (Coast-Typen dynamisch)
|
||
const placementPos = { x: 0, y: 0, z: 0 };
|
||
const placementRaycaster = new THREE.Raycaster();
|
||
const placementNDC = new THREE.Vector2();
|
||
const placeHint = $('#place-hint');
|
||
|
||
/** Maßnahmen, deren Längsachse parallel zur Küste ausgerichtet werden soll. */
|
||
function isCoastMeasure(id) {
|
||
return id === 'dike' || id === 'sea-wall' || id === 'mangrove'
|
||
|| id === 'sand-fill' || id === 'bikes';
|
||
}
|
||
|
||
/**
|
||
* coastRotation(x, z) — Rotationswinkel um Y, sodass die +X-Achse eines
|
||
* Meshes tangential zur Insel-Ellipsen-Küste zeigt. Normale ∝ (x/a², z/b²),
|
||
* Tangente ist senkrecht dazu. Für Three.js: rotation.y = atan2(-tz, tx).
|
||
*/
|
||
function coastRotation(x, z) {
|
||
return Math.atan2(
|
||
-x / (ISLAND_HALF_WIDTH * ISLAND_HALF_WIDTH),
|
||
-z / (ISLAND_HALF_LENGTH * ISLAND_HALF_LENGTH)
|
||
);
|
||
}
|
||
|
||
// Länge eines Radweg-Mesh-Asphalt-Bands (siehe makeBikePath: BoxGeometry 1.4)
|
||
const BIKE_LENGTH = 1.4;
|
||
const BIKE_SNAP_RADIUS = 2.2; // ab dieser Nähe zu bestehendem Radweg andocken
|
||
|
||
/**
|
||
* snapBikesPlacement(rawX, rawZ) — wenn ein bestehender Radweg in der
|
||
* Nähe ist, gibt es die Position zurück, an der das neue Segment
|
||
* bündig an dessen Stirnseite andockt (gleiche Rotation, Abstand =
|
||
* BIKE_LENGTH entlang seiner Tangente, vor- oder rückwärts je nachdem
|
||
* wo der Cursor sitzt). Gibt null zurück, wenn nichts zum Snappen da.
|
||
*/
|
||
function snapBikesPlacement(rawX, rawZ) {
|
||
const bikes = state.ownedMeasures && state.ownedMeasures.bikes;
|
||
if (!bikes || !bikes.instances || !bikes.instances.length) return null;
|
||
let best = null, bestDist = Infinity;
|
||
for (const inst of bikes.instances) {
|
||
if (!inst.pos) continue;
|
||
const dx = rawX - inst.pos.x;
|
||
const dz = rawZ - inst.pos.z;
|
||
const d = Math.sqrt(dx*dx + dz*dz);
|
||
if (d < bestDist) { bestDist = d; best = inst; }
|
||
}
|
||
if (!best || bestDist > BIKE_SNAP_RADIUS) return null;
|
||
|
||
// Lokales +X des bestehenden Mesh in Welt (rotation.y um Y):
|
||
// (1,0,0) → (cos rot, 0, −sin rot)
|
||
const rot = best.rotation || 0;
|
||
const fx = Math.cos(rot);
|
||
const fz = -Math.sin(rot);
|
||
// Seite entscheiden: liegt rawPos vor oder hinter dem bestehenden Zentrum?
|
||
const relX = rawX - best.pos.x;
|
||
const relZ = rawZ - best.pos.z;
|
||
const dir = (relX * fx + relZ * fz) >= 0 ? 1 : -1;
|
||
// Leicht überlappender Abstand (~92 % der Mesh-Länge), damit die Segmente
|
||
// visuell ineinander greifen statt Lücken zu lassen.
|
||
const SPACING = BIKE_LENGTH * 0.92;
|
||
const snapX = best.pos.x + dir * SPACING * fx;
|
||
const snapZ = best.pos.z + dir * SPACING * fz;
|
||
const snapY = Math.max(terrainHeight(snapX, snapZ), 0);
|
||
// Rotation am NEUEN Mittelpunkt aus coastRotation — so folgt die Kette
|
||
// der Küstenbiegung, Glied für Glied leicht gedreht.
|
||
const newRot = coastRotation(snapX, snapZ);
|
||
return { x: snapX, y: snapY, z: snapZ, rotation: newRot };
|
||
}
|
||
|
||
/**
|
||
* updateZoneOverlay(id) — setzt per-Vertex RGBA-Farben im zoneOverlay
|
||
* basierend auf isZoneValid. Valide Bereiche werden grün-translucent,
|
||
* invalide bekommen alpha=0 und werden vom Fragment-Shader verworfen.
|
||
* Wird aus startPlacement() gerufen.
|
||
*/
|
||
function updateZoneOverlay(id) {
|
||
const zone = MEASURE_ZONES_3D[id];
|
||
if (!zone || zone.kind === 'sea') {
|
||
// Sea-Zone (cloud-seed): keine Insel-Overlay — der Hint-Text führt.
|
||
zoneOverlay.visible = false;
|
||
return;
|
||
}
|
||
const pos = zoneOverlayGeo.attributes.position;
|
||
const col = zoneOverlayGeo.attributes.color4;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
|
||
const valid = isZoneValid(id, x, z, y);
|
||
if (valid) { col.setXYZW(i, 0.25, 0.85, 0.35, 0.50); }
|
||
else { col.setXYZW(i, 0, 0, 0, 0); }
|
||
}
|
||
col.needsUpdate = true;
|
||
zoneOverlay.visible = true;
|
||
}
|
||
|
||
function isZoneValid(id, x, z, y) {
|
||
const zone = MEASURE_ZONES_3D[id];
|
||
if (!zone) return true;
|
||
if (zone.kind === 'sea') {
|
||
// Offenes Meer = unter Wasserlinie und ausserhalb der Insel
|
||
if (y > 0.1) return false;
|
||
const edge = Math.sqrt((x/ISLAND_HALF_WIDTH)**2 + (z/ISLAND_HALF_LENGTH)**2);
|
||
return edge > 0.95;
|
||
}
|
||
if (zone.kind === 'edge') {
|
||
return y > -0.3 && y < 0.45;
|
||
}
|
||
if (zone.kind === 'coastband') {
|
||
// Schmales Band am Übergang Strand → Wiese: knapp über ZONE_BEACH_END
|
||
// (0.38) bis Beginn Wald-Zone. Für Radwege direkt am Strand entlang.
|
||
return y >= 0.32 && y <= 0.62;
|
||
}
|
||
// Land: Höhe im Fenster
|
||
return y >= (zone.minH || 0.4) && y <= (zone.maxH || 1.8);
|
||
}
|
||
|
||
function startPlacement(id, onConfirm) {
|
||
if (placementId) cancelPlacement();
|
||
if (expertMode) exitExpertMode(); // Experten- und Bau-Modus schließen sich aus
|
||
placementId = id;
|
||
placementOnConfirm = onConfirm;
|
||
placementRotation = isCoastMeasure(id) ? 0 : (Math.random() * Math.PI * 2);
|
||
droneActive = false;
|
||
droneResumeAt = Number.POSITIVE_INFINITY; // erst nach Bestätigung / Abbruch
|
||
|
||
const ghost = createMeasureMesh(id);
|
||
if (!ghost) { placementId = null; return; }
|
||
placementGhostMats = [];
|
||
ghost.traverse((obj) => {
|
||
if (obj.isMesh && obj.material) {
|
||
const clone = obj.material.clone();
|
||
clone.transparent = true;
|
||
clone.opacity = 0.55;
|
||
clone.depthWrite = false;
|
||
obj.material = clone;
|
||
placementGhostMats.push(clone);
|
||
}
|
||
});
|
||
ghost.position.set(0, -1000, 0); // ausserhalb Sicht, bis Maus bewegt wird
|
||
ghost.rotation.y = placementRotation;
|
||
scene.add(ghost);
|
||
placementGhost = ghost;
|
||
renderer.domElement.style.cursor = 'crosshair';
|
||
|
||
// Zonen-Overlay anzeigen (Sea-Zone bleibt aus, Hint-Text erklärt stattdessen)
|
||
updateZoneOverlay(id);
|
||
// „Bau beenden"-Karte in der Maßnahmen-Liste aktivieren
|
||
renderMeasures();
|
||
|
||
if (placeHint) {
|
||
const zone = MEASURE_ZONES_3D[id];
|
||
const defaultText = (zone && zone.kind === 'sea')
|
||
? 'Platziere auf dem offenen Meer vor der Küste · Rechtsklick / ESC: abbrechen'
|
||
: 'Grüne Fläche: hier kannst du platzieren · Rechtsklick / ESC: abbrechen';
|
||
placeHint.textContent = defaultText;
|
||
placeHint.classList.add('visible');
|
||
placeHint.classList.remove('invalid');
|
||
placeHint.dataset.default = defaultText;
|
||
}
|
||
}
|
||
|
||
function cancelPlacement() {
|
||
if (!placementId) return;
|
||
placementId = null;
|
||
placementOnConfirm = null;
|
||
if (placementGhost) {
|
||
scene.remove(placementGhost);
|
||
placementGhost.traverse((obj) => {
|
||
if (obj.isMesh) obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
|
||
});
|
||
placementGhostMats.forEach(m => m.dispose && m.dispose());
|
||
placementGhostMats = [];
|
||
placementGhost = null;
|
||
}
|
||
zoneOverlay.visible = false;
|
||
renderer.domElement.style.cursor = 'grab';
|
||
droneResumeAt = ((state.animMs || 0) / 1000) + 4;
|
||
if (placeHint) placeHint.classList.remove('visible', 'invalid');
|
||
// „Bau beenden"-Karte verschwindet wieder — Maßnahmen neu rendern
|
||
renderMeasures();
|
||
}
|
||
|
||
function updatePlacementGhost(clientX, clientY) {
|
||
if (!placementId || !placementGhost) return;
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
placementNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
placementNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
placementRaycaster.setFromCamera(placementNDC, camera);
|
||
|
||
const zone = MEASURE_ZONES_3D[placementId];
|
||
const targets = (zone && zone.kind === 'sea') ? [ocean, island] : [island, ocean];
|
||
const hits = placementRaycaster.intersectObjects(targets, false);
|
||
if (!hits.length) {
|
||
placementGhost.position.y = -1000;
|
||
placementValid = false;
|
||
return;
|
||
}
|
||
const hit = hits[0];
|
||
let x = hit.point.x, z = hit.point.z;
|
||
// Höhe immer aus Terrain-Formel lesen, nicht vom Raycast-Hit
|
||
// (für Maßnahmen auf der Insel, nicht fürs offene Meer).
|
||
let y;
|
||
if (zone && zone.kind === 'sea') y = WATER_Y_BASE + 0.1;
|
||
else y = Math.max(terrainHeight(x, z), 0);
|
||
|
||
// Coast-Typen: Längsachse parallel zur Küste ausrichten. Andere Maßnahmen
|
||
// behalten die einmal bei startPlacement gewürfelte Rotation.
|
||
if (isCoastMeasure(placementId)) placementRotation = coastRotation(x, z);
|
||
|
||
// Bike-Snap: wenn ein bestehender Radweg in Reichweite liegt, dock das
|
||
// Ghost an dessen Stirnseite an (wie Schienen). Länge = BIKE_LENGTH.
|
||
if (placementId === 'bikes') {
|
||
const snap = snapBikesPlacement(x, z);
|
||
if (snap) { x = snap.x; z = snap.z; y = snap.y; placementRotation = snap.rotation; }
|
||
}
|
||
|
||
placementGhost.position.set(x, y, z);
|
||
placementPos.x = x; placementPos.y = y; placementPos.z = z;
|
||
placementValid = isZoneValid(placementId, x, z, terrainHeight(x, z));
|
||
placementGhost.rotation.y = placementRotation;
|
||
|
||
// Ghost-Farbe: grün = valide, rot = nicht valide (via emissive)
|
||
const emColor = placementValid ? 0x2a7a2a : 0x8a2a1a;
|
||
for (const m of placementGhostMats) {
|
||
if (m.emissive) { m.emissive.setHex(emColor); m.emissiveIntensity = 0.45; }
|
||
}
|
||
if (placeHint) {
|
||
placeHint.classList.toggle('invalid', !placementValid);
|
||
if (placementValid) {
|
||
placeHint.textContent = placeHint.dataset.default || 'Linksklick: platzieren · Rechtsklick / ESC: abbrechen';
|
||
} else {
|
||
placeHint.textContent = 'Hier geht nicht — suche eine passende Stelle';
|
||
}
|
||
}
|
||
}
|
||
|
||
function confirmPlacement() {
|
||
if (!placementId || !placementValid) return;
|
||
const id = placementId;
|
||
const hitPos = { x: +placementPos.x.toFixed(2), y: +placementPos.y.toFixed(2), z: +placementPos.z.toFixed(2) };
|
||
const cb = placementOnConfirm;
|
||
cancelPlacement();
|
||
if (cb) cb(id, hitPos);
|
||
}
|
||
|
||
// Placement-Event-Integration: Linksklick auf leeres Canvas bestätigt,
|
||
// Rechtsklick und ESC brechen ab. Diese Listener laufen ZUSÄTZLICH zu den
|
||
// Orbit-Drag-Listenern; in onPointerDown unten wird Drag übersprungen,
|
||
// wenn placementId gesetzt ist. Move updatet den Ghost unabhängig.
|
||
renderer.domElement.addEventListener('contextmenu', (e) => {
|
||
if (placementId) { e.preventDefault(); cancelPlacement(); }
|
||
});
|
||
window.addEventListener('pointermove', (e) => {
|
||
if (placementId) updatePlacementGhost(e.clientX, e.clientY);
|
||
});
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && placementId) cancelPlacement();
|
||
});
|
||
|
||
/* ============================================================
|
||
EXPERTEN-FRAGEN — Query-Modus (kostenlos)
|
||
------------------------------------------------------------
|
||
Button im Header aktiviert den Modus; der Cursor wird zum Hilfe-
|
||
Zeiger, der Hint erklärt den Modus. Klick auf jedes Mesh mit
|
||
userData.infoKey öffnet die passende INFO_TOPICS-Karte.
|
||
ESC oder nochmaliger Button-Klick beenden den Modus.
|
||
============================================================ */
|
||
|
||
let expertMode = false;
|
||
|
||
function enterExpertMode() {
|
||
if (placementId) cancelPlacement();
|
||
expertMode = true;
|
||
renderer.domElement.style.cursor = 'help';
|
||
const btn = $('#btn-expert');
|
||
if (btn) btn.classList.add('active');
|
||
if (placeHint) {
|
||
placeHint.textContent = '👨🔬 Experten-Modus — klicke auf ein Objekt · ESC beendet';
|
||
placeHint.classList.add('visible');
|
||
placeHint.classList.remove('invalid');
|
||
}
|
||
}
|
||
function exitExpertMode() {
|
||
expertMode = false;
|
||
renderer.domElement.style.cursor = 'grab';
|
||
const btn = $('#btn-expert');
|
||
if (btn) btn.classList.remove('active');
|
||
if (placeHint) placeHint.classList.remove('visible');
|
||
}
|
||
function expertClickAt(clientX, clientY) {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
placementNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
placementNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
placementRaycaster.setFromCamera(placementNDC, camera);
|
||
const targets = [
|
||
measuresGroup, ambientGroup, villageGroup, volcanoGroup, island, ocean
|
||
].filter(Boolean);
|
||
if (mountainShield) targets.push(mountainShield);
|
||
const hits = placementRaycaster.intersectObjects(targets, true);
|
||
if (!hits.length) return;
|
||
// parent-Kette nach infoKey absuchen
|
||
let obj = hits[0].object;
|
||
while (obj && !(obj.userData && obj.userData.infoKey)) obj = obj.parent;
|
||
const key = obj && obj.userData && obj.userData.infoKey;
|
||
exitExpertMode();
|
||
if (key && INFO_TOPICS[key]) {
|
||
openInfoOverlay(key);
|
||
} else {
|
||
showEvent('🤷', 'Dazu weiß ich gerade nichts — frag später nochmal!', 'neutral');
|
||
}
|
||
}
|
||
|
||
const btnExpert = $('#btn-expert');
|
||
if (btnExpert) {
|
||
btnExpert.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
if (expertMode) exitExpertMode(); else enterExpertMode();
|
||
});
|
||
}
|
||
// Klick im Expert-Mode wird im Haupt-pointerdown-Handler (oben) abgefangen —
|
||
// hier reicht ESC als Exit. Contextmenu (Rechtsklick) verlässt ebenfalls.
|
||
renderer.domElement.addEventListener('contextmenu', (e) => {
|
||
if (expertMode) { e.preventDefault(); exitExpertMode(); }
|
||
});
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && expertMode) exitExpertMode();
|
||
});
|
||
|
||
/* ============================================================
|
||
UMGEBUNG — Boote, Helikopter, Start-Dorf, Vulkan-Schutzwall
|
||
------------------------------------------------------------
|
||
Animiertes Inselleben, das unabhängig vom Kauf-Flow läuft.
|
||
Einmalig beim Level-Start aufgebaut, pro Frame in updateAmbient()
|
||
via tNow = state.animMs/1000 bewegt (Pause friert alles ein).
|
||
============================================================ */
|
||
|
||
const ambientGroup = new THREE.Group();
|
||
scene.add(ambientGroup);
|
||
|
||
// --- Boote ---
|
||
// Fischerboot: Rumpf mit spitzem Bug, Holz-Deck, Steuerhaus mit Fenster-
|
||
// band, kleine Fahne und Fangnetz hinten. Bug zeigt in +X (lokal).
|
||
function makeMotorBoat() {
|
||
const g = new THREE.Group();
|
||
const hullMat = new THREE.MeshStandardMaterial({ color: 0x7a3020, roughness: 1 });
|
||
const deckMat = new THREE.MeshStandardMaterial({ color: 0xb8935a, roughness: 1 });
|
||
const cabinMat = new THREE.MeshStandardMaterial({ color: 0xf0e5ca, roughness: 1 });
|
||
const windowMat = new THREE.MeshStandardMaterial({ color: 0x5a7080, roughness: 0.4, metalness: 0.3 });
|
||
const trimMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 1 });
|
||
|
||
// Rumpf mit spitzem Bug via Shape→Extrude; Bug zeigt in +X.
|
||
const hullShape = new THREE.Shape();
|
||
hullShape.moveTo(-0.75, -0.26);
|
||
hullShape.lineTo( 0.45, -0.26);
|
||
hullShape.lineTo( 0.85, 0.0);
|
||
hullShape.lineTo( 0.45, 0.26);
|
||
hullShape.lineTo(-0.75, 0.26);
|
||
hullShape.closePath();
|
||
const hullGeo = new THREE.ExtrudeGeometry(hullShape, { depth: 0.28, bevelEnabled: false });
|
||
hullGeo.rotateX(-Math.PI / 2); // Extrusion +Z → +Y (Rumpf steht aufrecht)
|
||
const hull = new THREE.Mesh(hullGeo, hullMat);
|
||
g.add(hull);
|
||
|
||
// Deck (Holzfarben) — leicht schmaler als Rumpf, liegt oben drauf
|
||
const deckShape = new THREE.Shape();
|
||
deckShape.moveTo(-0.72, -0.23);
|
||
deckShape.lineTo( 0.42, -0.23);
|
||
deckShape.lineTo( 0.80, 0.0);
|
||
deckShape.lineTo( 0.42, 0.23);
|
||
deckShape.lineTo(-0.72, 0.23);
|
||
deckShape.closePath();
|
||
const deckGeo = new THREE.ExtrudeGeometry(deckShape, { depth: 0.03, bevelEnabled: false });
|
||
deckGeo.rotateX(-Math.PI / 2);
|
||
const deck = new THREE.Mesh(deckGeo, deckMat);
|
||
deck.position.y = 0.28;
|
||
g.add(deck);
|
||
|
||
// Steuerhaus mit Fensterband
|
||
const cabin = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.32, 0.36), cabinMat);
|
||
cabin.position.set(-0.15, 0.47, 0); g.add(cabin);
|
||
const windows = new THREE.Mesh(new THREE.BoxGeometry(0.49, 0.09, 0.37), windowMat);
|
||
windows.position.set(-0.15, 0.54, 0); g.add(windows);
|
||
const roofTrim = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.025, 0.40), trimMat);
|
||
roofTrim.position.set(-0.15, 0.635, 0); g.add(roofTrim);
|
||
|
||
// Mast mit Fahne
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.5, 6), trimMat);
|
||
pole.position.set(-0.15, 0.90, 0); g.add(pole);
|
||
const flag = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(0.20, 0.11),
|
||
new THREE.MeshStandardMaterial({ color: 0xd04040, roughness: 1, side: THREE.DoubleSide })
|
||
);
|
||
flag.position.set(-0.05, 1.08, 0); g.add(flag);
|
||
|
||
// Fangnetz am Heck (Torus-Ring, deutet Netzrolle an)
|
||
const net = new THREE.Mesh(
|
||
new THREE.TorusGeometry(0.13, 0.035, 4, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0x3a5020, roughness: 1 })
|
||
);
|
||
net.position.set(-0.65, 0.38, 0);
|
||
net.rotation.x = Math.PI / 2;
|
||
g.add(net);
|
||
|
||
// Positionslicht vorne (kleine gelbe Kugel)
|
||
const bowLight = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.04, 6, 4),
|
||
new THREE.MeshBasicMaterial({ color: 0xffd070 })
|
||
);
|
||
bowLight.position.set(0.78, 0.34, 0); g.add(bowLight);
|
||
|
||
return g;
|
||
}
|
||
|
||
// Größere Yacht mit zwei Segeln, weißer Rumpf mit blauem Streifen, Keel
|
||
// unter dem Rumpf, Baum am Hauptmast, Wimpel oben. Bug in +X.
|
||
function makeSailboat2() {
|
||
const g = new THREE.Group();
|
||
const hullMat = new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 1 });
|
||
const trimMat = new THREE.MeshStandardMaterial({ color: 0x2a4a7a, roughness: 1 });
|
||
const deckMat = new THREE.MeshStandardMaterial({ color: 0xb8935a, roughness: 1 });
|
||
const keelMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 1 });
|
||
const mastMat = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.5 });
|
||
const sailMat = new THREE.MeshStandardMaterial({ color: 0xfafaf2, roughness: 1, side: THREE.DoubleSide });
|
||
const jibMat = new THREE.MeshStandardMaterial({ color: 0xe6d2a8, roughness: 1, side: THREE.DoubleSide });
|
||
const flagMat = new THREE.MeshStandardMaterial({ color: 0x2050a0, roughness: 1, side: THREE.DoubleSide });
|
||
|
||
// Rumpf (Extrude)
|
||
const hullShape = new THREE.Shape();
|
||
hullShape.moveTo(-0.9, -0.22);
|
||
hullShape.lineTo( 0.6, -0.22);
|
||
hullShape.lineTo( 1.1, 0.0);
|
||
hullShape.lineTo( 0.6, 0.22);
|
||
hullShape.lineTo(-0.9, 0.22);
|
||
hullShape.closePath();
|
||
const hullGeo = new THREE.ExtrudeGeometry(hullShape, { depth: 0.28, bevelEnabled: false });
|
||
hullGeo.rotateX(-Math.PI / 2);
|
||
g.add(new THREE.Mesh(hullGeo, hullMat));
|
||
|
||
// Blauer Streifen entlang der Bordwand
|
||
const trim = new THREE.Mesh(new THREE.BoxGeometry(1.95, 0.045, 0.46), trimMat);
|
||
trim.position.set(-0.1, 0.20, 0); g.add(trim);
|
||
|
||
// Teakdeck (leicht schmaler als Rumpf)
|
||
const deckShape = new THREE.Shape();
|
||
deckShape.moveTo(-0.87, -0.19);
|
||
deckShape.lineTo( 0.56, -0.19);
|
||
deckShape.lineTo( 1.05, 0.0);
|
||
deckShape.lineTo( 0.56, 0.19);
|
||
deckShape.lineTo(-0.87, 0.19);
|
||
deckShape.closePath();
|
||
const deckGeo = new THREE.ExtrudeGeometry(deckShape, { depth: 0.03, bevelEnabled: false });
|
||
deckGeo.rotateX(-Math.PI / 2);
|
||
const deck = new THREE.Mesh(deckGeo, deckMat);
|
||
deck.position.y = 0.28;
|
||
g.add(deck);
|
||
|
||
// Cockpit-Vertiefung hinten
|
||
const cockpit = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.04, 0.3), keelMat);
|
||
cockpit.position.set(-0.55, 0.30, 0);
|
||
g.add(cockpit);
|
||
|
||
// Kiel (Flosse nach unten)
|
||
const keel = new THREE.Mesh(new THREE.BoxGeometry(0.85, 0.35, 0.07), keelMat);
|
||
keel.position.set(-0.1, -0.17, 0); g.add(keel);
|
||
|
||
// Hauptmast + Baum
|
||
const mainMast = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.035, 0.035, 2.3, 8),
|
||
mastMat
|
||
);
|
||
mainMast.position.set(-0.1, 1.42, 0); g.add(mainMast);
|
||
const boom = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.025, 0.025, 1.2, 6),
|
||
mastMat
|
||
);
|
||
boom.rotation.z = Math.PI / 2;
|
||
boom.position.set(-0.55, 0.52, 0);
|
||
g.add(boom);
|
||
|
||
// Hauptsegel als Dreieck (Bermuda-Rigg)
|
||
const mainShape = new THREE.Shape();
|
||
mainShape.moveTo(0, 0); // Tack am Mastfuß
|
||
mainShape.lineTo(-1.05, 0); // Clew am Baumende
|
||
mainShape.lineTo(0, 2.05); // Head an der Mastspitze
|
||
mainShape.closePath();
|
||
const mainSail = new THREE.Mesh(new THREE.ShapeGeometry(mainShape), sailMat);
|
||
mainSail.position.set(-0.1, 0.52, 0); g.add(mainSail);
|
||
|
||
// Fock als Dreieck vor dem Mast
|
||
const jibShape = new THREE.Shape();
|
||
jibShape.moveTo(0, 0); // Tack am Bug
|
||
jibShape.lineTo(-1.15, 1.88); // Head oben am Mast
|
||
jibShape.lineTo(-0.85, 0.12); // Clew auf dem Deck
|
||
jibShape.closePath();
|
||
const jib = new THREE.Mesh(new THREE.ShapeGeometry(jibShape), jibMat);
|
||
jib.position.set(1.05, 0.28, 0); g.add(jib);
|
||
|
||
// Wimpel am Mastkopf
|
||
const flagPole = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.008, 0.008, 0.15, 4),
|
||
mastMat
|
||
);
|
||
flagPole.position.set(-0.1, 2.42, 0); g.add(flagPole);
|
||
const flag = new THREE.Mesh(new THREE.PlaneGeometry(0.14, 0.07), flagMat);
|
||
flag.position.set(-0.03, 2.45, 0); g.add(flag);
|
||
|
||
// Halbe Größe: kleiner als Tanker/Fischerboot
|
||
g.scale.setScalar(0.5);
|
||
return g;
|
||
}
|
||
function makeTanker() {
|
||
const g = new THREE.Group();
|
||
const hull = new THREE.Mesh(
|
||
new THREE.BoxGeometry(6.0, 0.55, 1.3),
|
||
new THREE.MeshStandardMaterial({ color: 0x3a4550, roughness: 1 })
|
||
);
|
||
hull.position.y = 0.27; g.add(hull);
|
||
const deck = new THREE.Mesh(
|
||
new THREE.BoxGeometry(5.2, 0.18, 1.05),
|
||
new THREE.MeshStandardMaterial({ color: 0x6a7580, roughness: 1 })
|
||
);
|
||
deck.position.y = 0.63; g.add(deck);
|
||
const bridge = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.9, 0.65, 1.0),
|
||
new THREE.MeshStandardMaterial({ color: 0xe0e0e0, roughness: 1 })
|
||
);
|
||
bridge.position.set(-2.3, 1.04, 0); g.add(bridge);
|
||
const stack = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.12, 0.14, 0.55, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x4a4550, roughness: 1 })
|
||
);
|
||
stack.position.set(-2.3, 1.6, 0); g.add(stack);
|
||
return g;
|
||
}
|
||
// Helper: bläht eine PlaneGeometry in Z-Richtung — sin×sin-Kuppel, zentriert
|
||
// bei (0,0). Sieht aus wie ein winddurchwehtes Segel statt flacher Pappe.
|
||
function billowSail(geo, amp) {
|
||
const pos = geo.attributes.position;
|
||
const params = geo.parameters || {};
|
||
const w = params.width || 1;
|
||
const h = params.height || 1;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const x = pos.getX(i), y = pos.getY(i);
|
||
const nx = (x + w * 0.5) / w; // 0..1 über die Breite
|
||
const ny = (y + h * 0.5) / h; // 0..1 über die Höhe
|
||
pos.setZ(i, Math.sin(nx * Math.PI) * Math.sin(ny * Math.PI) * amp);
|
||
}
|
||
geo.computeVertexNormals();
|
||
return geo;
|
||
}
|
||
|
||
// Kleiner klassischer Sloop: Extrudierter Rumpf mit spitzem Bug, Teak-Deck,
|
||
// Mast mit Baum, ein großes gebauchtes Hauptsegel + kleine Fock, Wimpel
|
||
// am Masttop, flacher Kiel unter dem Rumpf. Bug in +X.
|
||
function makeSailboat() {
|
||
const g = new THREE.Group();
|
||
const hullMat = new THREE.MeshStandardMaterial({ color: 0x6a3a1a, roughness: 1 });
|
||
const deckMat = new THREE.MeshStandardMaterial({ color: 0xc8a070, roughness: 1 });
|
||
const keelMat = new THREE.MeshStandardMaterial({ color: 0x2a1a10, roughness: 1 });
|
||
const mastMat = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.5 });
|
||
const sailMat = new THREE.MeshStandardMaterial({ color: 0xfafaf2, roughness: 1, side: THREE.DoubleSide });
|
||
const jibMat = new THREE.MeshStandardMaterial({ color: 0xe6d2a8, roughness: 1, side: THREE.DoubleSide });
|
||
const flagMat = new THREE.MeshStandardMaterial({ color: 0xc03030, roughness: 1, side: THREE.DoubleSide });
|
||
|
||
// Rumpf (Extrude, Bug in +X)
|
||
const hullShape = new THREE.Shape();
|
||
hullShape.moveTo(-0.75, -0.22);
|
||
hullShape.lineTo( 0.40, -0.22);
|
||
hullShape.lineTo( 0.90, 0.0);
|
||
hullShape.lineTo( 0.40, 0.22);
|
||
hullShape.lineTo(-0.75, 0.22);
|
||
hullShape.closePath();
|
||
const hullGeo = new THREE.ExtrudeGeometry(hullShape, { depth: 0.26, bevelEnabled: false });
|
||
hullGeo.rotateX(-Math.PI / 2);
|
||
g.add(new THREE.Mesh(hullGeo, hullMat));
|
||
|
||
// Deck (Teak-Streifen, etwas schmaler als Rumpf)
|
||
const deckShape = new THREE.Shape();
|
||
deckShape.moveTo(-0.72, -0.19);
|
||
deckShape.lineTo( 0.36, -0.19);
|
||
deckShape.lineTo( 0.84, 0.0);
|
||
deckShape.lineTo( 0.36, 0.19);
|
||
deckShape.lineTo(-0.72, 0.19);
|
||
deckShape.closePath();
|
||
const deckGeo = new THREE.ExtrudeGeometry(deckShape, { depth: 0.03, bevelEnabled: false });
|
||
deckGeo.rotateX(-Math.PI / 2);
|
||
const deck = new THREE.Mesh(deckGeo, deckMat);
|
||
deck.position.y = 0.26;
|
||
g.add(deck);
|
||
|
||
// Cockpit-Vertiefung (dunkles Rechteck im Heckbereich)
|
||
const cockpit = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.35, 0.04, 0.24),
|
||
keelMat
|
||
);
|
||
cockpit.position.set(-0.45, 0.28, 0);
|
||
g.add(cockpit);
|
||
|
||
// Flacher Kiel unter dem Rumpf (Stabilität, visuelle Tiefe)
|
||
const keel = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.7, 0.22, 0.06),
|
||
keelMat
|
||
);
|
||
keel.position.set(-0.05, -0.11, 0);
|
||
g.add(keel);
|
||
|
||
// Mast + Baum
|
||
const mast = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.025, 0.025, 1.7, 8),
|
||
mastMat
|
||
);
|
||
mast.position.set(0.05, 1.13, 0);
|
||
g.add(mast);
|
||
const boom = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.02, 0.02, 0.9, 6),
|
||
mastMat
|
||
);
|
||
boom.rotation.z = Math.PI / 2;
|
||
boom.position.set(-0.35, 0.40, 0);
|
||
g.add(boom);
|
||
|
||
// Hauptsegel als Dreieck (Bermuda-Rigg): Tack am Mastfuß, Clew am
|
||
// Baum-Ende, Head an der Mastspitze. ShapeGeometry bleibt flach — passt
|
||
// zum „Standard-Segelyacht"-Look.
|
||
const mainShape = new THREE.Shape();
|
||
mainShape.moveTo(0, 0); // Tack
|
||
mainShape.lineTo(-0.85, 0); // Clew (Baum aft)
|
||
mainShape.lineTo(0, 1.58); // Head (Mastspitze)
|
||
mainShape.closePath();
|
||
const mainSail = new THREE.Mesh(new THREE.ShapeGeometry(mainShape), sailMat);
|
||
mainSail.position.set(0.05, 0.40, 0);
|
||
g.add(mainSail);
|
||
|
||
// Fock als kleineres Dreieck vor dem Mast
|
||
const jibShape = new THREE.Shape();
|
||
jibShape.moveTo(0, 0); // Tack am Bug
|
||
jibShape.lineTo(-0.75, 1.48); // Head oben am Mast
|
||
jibShape.lineTo(-0.55, 0.04); // Clew am Deck mittig
|
||
jibShape.closePath();
|
||
const jib = new THREE.Mesh(new THREE.ShapeGeometry(jibShape), jibMat);
|
||
jib.position.set(0.80, 0.26, 0);
|
||
g.add(jib);
|
||
|
||
// Wimpel am Masttop
|
||
const flagPole = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.007, 0.007, 0.14, 4),
|
||
mastMat
|
||
);
|
||
flagPole.position.set(0.05, 2.05, 0);
|
||
g.add(flagPole);
|
||
const flag = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(0.12, 0.06),
|
||
flagMat
|
||
);
|
||
flag.position.set(0.13, 2.08, 0);
|
||
g.add(flag);
|
||
|
||
// Halbe Größe: Standard-Segelyacht-Look, kleiner als Fischerboot
|
||
g.scale.setScalar(0.5);
|
||
return g;
|
||
}
|
||
const patrolBoat = makeMotorBoat();
|
||
patrolBoat.userData.infoKey = 'fishing_info';
|
||
ambientGroup.add(patrolBoat);
|
||
const tanker = makeTanker();
|
||
tanker.userData.infoKey = 'tanker_info';
|
||
ambientGroup.add(tanker);
|
||
const sailboat = makeSailboat();
|
||
sailboat.userData.infoKey = 'sailboat_info';
|
||
ambientGroup.add(sailboat);
|
||
const sailboat2 = makeSailboat2();
|
||
sailboat2.userData.infoKey = 'sailboat_info';
|
||
ambientGroup.add(sailboat2);
|
||
|
||
function updateBoats(tNow) {
|
||
const waterY = ocean.position.y + 0.05;
|
||
// Fischerboot: Halbkreis-Patrouille vor der Insel (Z > 0)
|
||
const pA = tNow * 0.10;
|
||
const t = (Math.sin(pA) + 1) / 2;
|
||
const sweep = Math.PI * 0.9;
|
||
const theta = -sweep / 2 + t * sweep;
|
||
const prx = ISLAND_HALF_WIDTH + 3;
|
||
const prz = ISLAND_HALF_LENGTH + 4;
|
||
const pbx = Math.sin(theta) * prx;
|
||
const pbz = Math.cos(theta) * prz;
|
||
patrolBoat.position.set(pbx, waterY, pbz);
|
||
// Kurs-Richtung = tangential (Ableitung); Math.cos(pA) >0 → theta wächst
|
||
const goingRight = Math.cos(pA) >= 0;
|
||
patrolBoat.rotation.y = theta + (goingRight ? 0 : Math.PI);
|
||
|
||
// Tanker: quert hinter der Insel von −X nach +X → Bug in +X → rotation.y = 0
|
||
const cycleT = 95;
|
||
const uT = (tNow % cycleT) / cycleT;
|
||
tanker.position.set(-70 + uT * 140, waterY, -ISLAND_HALF_LENGTH - 14);
|
||
tanker.rotation.y = 0;
|
||
|
||
// Segelboot 1: +X → -X → Bug in -X → rotation.y = π
|
||
const cycleS = 68;
|
||
const uS = (tNow % cycleS) / cycleS;
|
||
sailboat.position.set(68 - uS * 140, waterY, -ISLAND_HALF_LENGTH - 7);
|
||
sailboat.rotation.y = Math.PI;
|
||
|
||
// Segelboot 2 (Yacht): quert VORNE, -X → +X → Bug in +X → rotation.y = 0
|
||
const cycleS2 = 82;
|
||
const uS2 = (tNow % cycleS2) / cycleS2;
|
||
sailboat2.position.set(-70 + uS2 * 140, waterY, ISLAND_HALF_LENGTH + 8);
|
||
sailboat2.rotation.y = 0;
|
||
}
|
||
|
||
// --- Helikopter (nur wenn Flughafen gebaut ist) ---
|
||
function makeHeli() {
|
||
const g = new THREE.Group();
|
||
const body = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.22, 12, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xc04040, roughness: 0.8 })
|
||
);
|
||
body.scale.set(1.6, 0.9, 1.0);
|
||
g.add(body);
|
||
const tail = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.05, 0.08, 0.9, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xc04040, roughness: 0.8 })
|
||
);
|
||
tail.rotation.z = Math.PI / 2;
|
||
tail.position.x = -0.5;
|
||
g.add(tail);
|
||
const tailFin = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.08, 0.22, 0.03),
|
||
new THREE.MeshStandardMaterial({ color: 0x902020, roughness: 1 })
|
||
);
|
||
tailFin.position.set(-0.95, 0.1, 0); g.add(tailFin);
|
||
const rotor = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.6, 0.04, 0.08),
|
||
new THREE.MeshStandardMaterial({ color: 0x303030, roughness: 1 })
|
||
);
|
||
rotor.position.y = 0.28;
|
||
g.add(rotor);
|
||
g.userData.rotorRef = rotor;
|
||
const skid1 = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.025, 0.025, 0.6, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0x202020, roughness: 1 })
|
||
);
|
||
skid1.rotation.z = Math.PI / 2;
|
||
skid1.position.set(0, -0.2, 0.15); g.add(skid1);
|
||
const skid2 = skid1.clone();
|
||
skid2.position.z = -0.15; g.add(skid2);
|
||
return g;
|
||
}
|
||
// --- Flugzeug (Cessna-artig, low-poly) ---
|
||
// Wird pro Flughafen einmal angelegt. Macht einen langen Zyklus (Parken,
|
||
// Start, Wegflug, Rückflug, Landung, Taxi). Propeller dreht konstant.
|
||
function makePlane() {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xe8e8ea, roughness: 0.7 });
|
||
const trimMat = new THREE.MeshStandardMaterial({ color: 0x2a4a6a, roughness: 0.8 });
|
||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 1 });
|
||
|
||
// Rumpf (Cylinder entlang X, Nase +X)
|
||
const fuselage = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.14, 0.10, 1.2, 10),
|
||
bodyMat
|
||
);
|
||
fuselage.rotation.z = Math.PI / 2;
|
||
g.add(fuselage);
|
||
// Cockpit (leicht dunkler Block oben)
|
||
const cockpit = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.13, 0.22), trimMat);
|
||
cockpit.position.set(0.12, 0.13, 0); g.add(cockpit);
|
||
// Flügel (über dem Rumpf, Hochdecker)
|
||
const wing = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.04, 1.6), bodyMat);
|
||
wing.position.set(0.0, 0.20, 0); g.add(wing);
|
||
// Heckflosse (vertikal)
|
||
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.25, 0.04), bodyMat);
|
||
tail.position.set(-0.55, 0.25, 0); g.add(tail);
|
||
// Höhenleitwerk (horizontal, Heck)
|
||
const htail = new THREE.Mesh(new THREE.BoxGeometry(0.20, 0.03, 0.5), bodyMat);
|
||
htail.position.set(-0.55, 0.18, 0); g.add(htail);
|
||
// Propeller-Nabe + Blätter
|
||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.08, 8), darkMat);
|
||
hub.rotation.z = Math.PI / 2;
|
||
hub.position.set(0.62, 0, 0); g.add(hub);
|
||
const prop = new THREE.Group();
|
||
prop.position.set(0.66, 0, 0);
|
||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.55, 0.04), darkMat);
|
||
prop.add(blade);
|
||
g.add(prop);
|
||
g.userData.propRef = prop;
|
||
// Fahrwerk (zwei kleine Räder)
|
||
for (const sx of [-0.1, 0.1]) {
|
||
const strut = new THREE.Mesh(new THREE.CylinderGeometry(0.01, 0.01, 0.15, 6), darkMat);
|
||
strut.position.set(sx, -0.14, 0); g.add(strut);
|
||
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.04, 10), darkMat);
|
||
wheel.rotation.x = Math.PI / 2;
|
||
wheel.position.set(sx, -0.22, 0); g.add(wheel);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
// --- Airport-Fahrzeuge pro Flughafen ---
|
||
// airportVehicles hält pro gebautem Airport einen Heli + Flugzeug.
|
||
// Jedes Paar bekommt einen individuellen Phase-Offset, damit Helis
|
||
// und Flugzeuge verschiedener Flughäfen nicht synchron laufen.
|
||
const airportVehicles = new Map(); // airport-i → { heli, plane, idx, phaseOffset }
|
||
|
||
function syncAirportVehicles() {
|
||
const airport = state.ownedMeasures && state.ownedMeasures.airport;
|
||
const keep = new Set();
|
||
if (airport && airport.instances) {
|
||
airport.instances.forEach((inst, idx) => {
|
||
const key = instanceKey('airport', idx);
|
||
keep.add(key);
|
||
if (airportVehicles.has(key)) return;
|
||
const h = makeHeli();
|
||
h.userData.infoKey = 'heli_info';
|
||
h.visible = false;
|
||
ambientGroup.add(h);
|
||
const p = makePlane();
|
||
p.userData.infoKey = 'aviation';
|
||
p.visible = false;
|
||
ambientGroup.add(p);
|
||
airportVehicles.set(key, { heli: h, plane: p, idx, phaseOffset: idx * 17 });
|
||
});
|
||
}
|
||
for (const key of Array.from(airportVehicles.keys())) {
|
||
if (keep.has(key)) continue;
|
||
const v = airportVehicles.get(key);
|
||
ambientGroup.remove(v.heli); ambientGroup.remove(v.plane);
|
||
airportVehicles.delete(key);
|
||
}
|
||
}
|
||
|
||
const HELI_CYCLE = 42;
|
||
const PLANE_CYCLE = 110;
|
||
|
||
function updateAirportVehicles(tNow) {
|
||
const airport = state.ownedMeasures && state.ownedMeasures.airport;
|
||
if (!airport) return;
|
||
for (const [key, v] of airportVehicles) {
|
||
const inst = airport.instances && airport.instances[v.idx];
|
||
if (!inst || !inst.pos) { v.heli.visible = false; v.plane.visible = false; continue; }
|
||
const airportMesh = placedMeshes.get(key);
|
||
if (!airportMesh) continue;
|
||
updateSingleHeli(v, tNow, airportMesh);
|
||
updateSinglePlane(v, tNow, airportMesh);
|
||
}
|
||
}
|
||
|
||
// Lokale→Welt-Transformation für Airport-relative Punkte (inkl. Airport-Rotation)
|
||
function airportLocalToWorld(airportMesh, lx, ly, lz) {
|
||
const cosR = Math.cos(airportMesh.rotation.y);
|
||
const sinR = Math.sin(airportMesh.rotation.y);
|
||
return {
|
||
x: airportMesh.position.x + lx * cosR - lz * sinR,
|
||
y: airportMesh.position.y + ly,
|
||
z: airportMesh.position.z + lx * sinR + lz * cosR,
|
||
};
|
||
}
|
||
|
||
function updateSingleHeli(v, tNow, airportMesh) {
|
||
const heli = v.heli;
|
||
heli.visible = true;
|
||
// Heli-Pad-Offset aus dem Airport-Mesh (inkl. LIFT-Korrektur), × Scale 2
|
||
const padLocalRaw = airportMesh.userData.heliPadLocal || { x: 0.85, y: 0.28, z: 0.55 };
|
||
const padLocal = { x: padLocalRaw.x * 2, y: padLocalRaw.y * 2, z: padLocalRaw.z * 2 };
|
||
const padWorld = airportLocalToWorld(airportMesh, padLocal.x, padLocal.y, padLocal.z);
|
||
const rotY = airportMesh.rotation.y;
|
||
|
||
const tPhase = tNow + v.phaseOffset;
|
||
const cycleT = tPhase % HELI_CYCLE;
|
||
const cycleNum = Math.floor(tPhase / HELI_CYCLE);
|
||
// Alle ~3 Zyklen macht der Heli statt der üblichen Airport-Runde eine
|
||
// größere Runde um den Vulkan (VOLCANO_X,VOLCANO_Z). Deterministisch
|
||
// abhängig von cycleNum + phaseOffset → synchron über Reloads.
|
||
const volcanoTour = (cycleNum % 3) === (Math.floor(v.phaseOffset) % 3);
|
||
let rotorSpeed = 20;
|
||
|
||
if (cycleT < 24) {
|
||
// Orbit-Phase: je nach Modus Airport-Kreis oder Vulkan-Runde
|
||
const a = tPhase * 0.45;
|
||
if (volcanoTour) {
|
||
// Großer Bogen: Zentrum Vulkan, Radius 9, Höhe steigt leicht
|
||
const cx = VOLCANO_X, cz = VOLCANO_Z;
|
||
heli.position.set(
|
||
cx + Math.cos(a) * 9,
|
||
airportMesh.position.y + 5.5 + Math.sin(tPhase * 0.8) * 0.3,
|
||
cz + Math.sin(a) * 9
|
||
);
|
||
heli.rotation.y = a + Math.PI / 2;
|
||
} else {
|
||
heli.position.set(
|
||
airportMesh.position.x + Math.cos(a) * 3.5,
|
||
airportMesh.position.y + 2.8 + Math.sin(tPhase * 0.9) * 0.15,
|
||
airportMesh.position.z + Math.sin(a) * 3.5
|
||
);
|
||
heli.rotation.y = a + Math.PI / 2;
|
||
}
|
||
} else if (cycleT < 28) {
|
||
// Landung: linear zurück zum Pad
|
||
const t = (cycleT - 24) / 4;
|
||
const a = 24 * 0.45;
|
||
const startX = volcanoTour ? VOLCANO_X + Math.cos(a) * 9 : airportMesh.position.x + Math.cos(a) * 3.5;
|
||
const startZ = volcanoTour ? VOLCANO_Z + Math.sin(a) * 9 : airportMesh.position.z + Math.sin(a) * 3.5;
|
||
const startY = airportMesh.position.y + (volcanoTour ? 5.5 : 2.8);
|
||
heli.position.set(
|
||
lerp(startX, padWorld.x, t),
|
||
lerp(startY, padWorld.y + 0.22, t),
|
||
lerp(startZ, padWorld.z, t)
|
||
);
|
||
heli.rotation.y = a + Math.PI / 2;
|
||
} else if (cycleT < 37) {
|
||
// Sitzt auf dem Pad — Rotor läuft aus und wieder an
|
||
heli.position.set(padWorld.x, padWorld.y + 0.22, padWorld.z);
|
||
heli.rotation.y = rotY + Math.PI / 2;
|
||
const sitT = cycleT - 28;
|
||
rotorSpeed = sitT < 2 ? 20 * (1 - sitT / 2) : (sitT > 7 ? 20 * (sitT - 7) / 2 : 0);
|
||
} else {
|
||
// Takeoff zurück in den Orbit-Startpunkt
|
||
const t = (cycleT - 37) / (HELI_CYCLE - 37);
|
||
const a = HELI_CYCLE * 0.45;
|
||
const endX = airportMesh.position.x + Math.cos(a) * 3.5;
|
||
const endZ = airportMesh.position.z + Math.sin(a) * 3.5;
|
||
const endY = airportMesh.position.y + 2.8;
|
||
heli.position.set(
|
||
lerp(padWorld.x, endX, t),
|
||
lerp(padWorld.y + 0.22, endY, t),
|
||
lerp(padWorld.z, endZ, t)
|
||
);
|
||
heli.rotation.y = rotY + Math.PI / 2;
|
||
}
|
||
const r = heli.userData.rotorRef;
|
||
if (r) r.rotation.y = tPhase * rotorSpeed;
|
||
}
|
||
|
||
function updateSinglePlane(v, tNow, airportMesh) {
|
||
const plane = v.plane;
|
||
const rotY = airportMesh.rotation.y;
|
||
const RUNWAY_END = 2.2; // lokal, nach 2x-Scale also 4.4 in Welt
|
||
const RUNWAY_Y = 0.50; // auf Plateau-Oberkante (LIFT+Scale²≈0.26)
|
||
|
||
// Zyklus-Phase (Sekunden) mit individuellem Offset pro Airport, plus
|
||
// +60 s Grund-Offset damit Plane nicht sofort beim Level-Start startet.
|
||
const tPhase = (tNow + v.phaseOffset + 60) % PLANE_CYCLE;
|
||
plane.visible = true;
|
||
const prop = plane.userData.propRef;
|
||
|
||
// Hilfsfunktion: Plane an lokal (lx, ly, lz) mit Rotation-Offset um Y
|
||
function place(lx, ly, lz, pitch, extraYaw) {
|
||
const w = airportLocalToWorld(airportMesh, lx, ly, lz);
|
||
plane.position.set(w.x, w.y, w.z);
|
||
// Basis-Yaw = airportRotY + extraYaw (π für Rollen nach -X, 0 für +X)
|
||
plane.rotation.set(pitch || 0, rotY + (extraYaw || 0), 0);
|
||
}
|
||
|
||
if (tPhase < 14) {
|
||
// Parken am Runway-Anfang (+X-Ende lokal), Nase zeigt nach Startrichtung (-X)
|
||
place(RUNWAY_END - 0.3, RUNWAY_Y, 0, 0, Math.PI);
|
||
} else if (tPhase < 18) {
|
||
// Takeoff-Roll: rollt von +X nach -X, hebt am Ende leicht ab
|
||
const t = (tPhase - 14) / 4;
|
||
const lx = (RUNWAY_END - 0.3) - t * (2 * RUNWAY_END - 0.6);
|
||
const ly = RUNWAY_Y + (t > 0.75 ? (t - 0.75) * 0.6 : 0);
|
||
place(lx, ly, 0, -t * 0.2, Math.PI);
|
||
} else if (tPhase < 26) {
|
||
// Climb-Out: steigt auf, entfernt sich weiter in -X-lokal
|
||
const t = (tPhase - 18) / 8;
|
||
const lx = -(RUNWAY_END - 0.3) - t * 18;
|
||
const ly = RUNWAY_Y + 0.15 + t * 4.0;
|
||
place(lx, ly, 0, -0.28, Math.PI);
|
||
} else if (tPhase < 80) {
|
||
// Unterwegs — unsichtbar (kommt von weit draußen zurück)
|
||
plane.visible = false;
|
||
} else if (tPhase < 90) {
|
||
// Approach: kommt in -X-lokal auf die Landung zu, sinkt
|
||
const t = (tPhase - 80) / 10;
|
||
const lx = -22 + t * (22 - (RUNWAY_END + 0.3));
|
||
const ly = 4.2 - t * 3.7; // 4.2 → 0.5
|
||
place(lx, ly, 0, 0.15 - t * 0.15, Math.PI);
|
||
} else if (tPhase < 94) {
|
||
// Landing-Roll: von -X nach +X mit leichtem Pitch-Up am Anfang, dann flach
|
||
const t = (tPhase - 90) / 4;
|
||
const lx = -(RUNWAY_END - 0.3) + t * (2 * RUNWAY_END - 0.6);
|
||
place(lx, RUNWAY_Y, 0, 0, Math.PI);
|
||
} else {
|
||
// Taxi + parken (zurück in Startposition)
|
||
place(RUNWAY_END - 0.3, RUNWAY_Y, 0, 0, Math.PI);
|
||
}
|
||
if (prop && plane.visible) prop.rotation.x = tPhase * 22;
|
||
}
|
||
|
||
// --- Start-Dorf: 10 Haus-Positionen auf der Insel, Sichtbarkeit skaliert mit Bevölkerung ---
|
||
const VILLAGE_POSITIONS = [
|
||
[-1.5, 2.5], [ 1.2, 2.0], [-2.8, 0.8], [ 2.5, -0.5],
|
||
[-0.5, -1.8], [ 1.8, -2.8], [-2.3, -3.4], [ 0.6, 4.2],
|
||
[ 3.4, 1.3], [-3.6, -1.5],
|
||
];
|
||
function makeVillageHouse() {
|
||
const g = new THREE.Group();
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.55, 0.4, 0.55),
|
||
new THREE.MeshStandardMaterial({ color: 0xe8d8bc, roughness: 1 })
|
||
);
|
||
body.position.y = 0.20; g.add(body);
|
||
const roof = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.46, 0.30, 4),
|
||
new THREE.MeshStandardMaterial({ color: 0xa04a3a, roughness: 1 })
|
||
);
|
||
roof.rotation.y = Math.PI / 4;
|
||
roof.position.y = 0.55; g.add(roof);
|
||
const chimney = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.09, 0.22, 0.09),
|
||
new THREE.MeshStandardMaterial({ color: 0x5a4a3a, roughness: 1 })
|
||
);
|
||
chimney.position.set(0.18, 0.63, -0.1); g.add(chimney);
|
||
g.userData.chimneyTop = { x: 0.18, y: 0.78, z: -0.1 };
|
||
g.userData.infoKey = 'village_info';
|
||
return g;
|
||
}
|
||
const villageGroup = new THREE.Group();
|
||
scene.add(villageGroup);
|
||
const villageHouses = []; // stabile Liste, nur Visibility toggelt mit Population
|
||
const smokePuffs = []; // [{mesh, age, lifetime, vy}]
|
||
const smokeGeom = new THREE.SphereGeometry(0.08, 6, 5);
|
||
|
||
function buildVillage() {
|
||
// Alte Häuser + Puffs raus
|
||
for (const h of villageHouses) villageGroup.remove(h);
|
||
villageHouses.length = 0;
|
||
for (const p of smokePuffs) villageGroup.remove(p.mesh);
|
||
smokePuffs.length = 0;
|
||
for (const [px, pz] of VILLAGE_POSITIONS) {
|
||
const y = terrainHeight(px, pz);
|
||
if (y < 0.3) continue;
|
||
const h = makeVillageHouse();
|
||
h.position.set(px, y, pz);
|
||
h.rotation.y = Math.random() * Math.PI * 2;
|
||
villageGroup.add(h);
|
||
villageHouses.push(h);
|
||
}
|
||
}
|
||
|
||
function updateVillage(tNow, dt) {
|
||
const pop = state.population || 6000;
|
||
const visibleCount = Math.max(2, Math.min(villageHouses.length, Math.ceil(pop / 800)));
|
||
villageHouses.forEach((h, i) => { h.visible = i < visibleCount; });
|
||
|
||
// Rauch: Anteil fossiler Energie → je mehr Kohle, desto mehr Häuser rauchen
|
||
const demand = KlimaEngine.getPowerDemand(state);
|
||
const renew = state.renewablePower || 0;
|
||
const fossilRatio = Math.max(0, Math.min(1, 1 - renew / Math.max(1, demand)));
|
||
// Spawn-Rate pro Haus pro Sekunde (kohleintensiv ~0.6 Puffs/s, erneuerbar ~0)
|
||
const spawnPerS = fossilRatio * 0.5;
|
||
|
||
for (let i = 0; i < visibleCount; i++) {
|
||
const h = villageHouses[i];
|
||
if (!h) continue;
|
||
if (Math.random() < spawnPerS * dt) {
|
||
const top = h.userData.chimneyTop;
|
||
if (!top) continue;
|
||
const puff = new THREE.Mesh(
|
||
smokeGeom,
|
||
new THREE.MeshBasicMaterial({ color: 0xbbbbbb, transparent: true, opacity: 0.6, depthWrite: false })
|
||
);
|
||
puff.position.set(
|
||
h.position.x + top.x + (Math.random() - 0.5) * 0.04,
|
||
h.position.y + top.y,
|
||
h.position.z + top.z + (Math.random() - 0.5) * 0.04
|
||
);
|
||
villageGroup.add(puff);
|
||
smokePuffs.push({ mesh: puff, age: 0, lifetime: 2.5 + Math.random() * 1.5, vy: 0.35 + Math.random() * 0.2 });
|
||
}
|
||
}
|
||
for (let i = smokePuffs.length - 1; i >= 0; i--) {
|
||
const p = smokePuffs[i];
|
||
p.age += dt;
|
||
if (p.age >= p.lifetime) {
|
||
villageGroup.remove(p.mesh);
|
||
p.mesh.material.dispose && p.mesh.material.dispose();
|
||
smokePuffs.splice(i, 1);
|
||
continue;
|
||
}
|
||
p.mesh.position.y += p.vy * dt;
|
||
p.mesh.position.x += 0.08 * dt; // leichter Wind
|
||
const t = p.age / p.lifetime;
|
||
p.mesh.material.opacity = (1 - t) * 0.6;
|
||
p.mesh.scale.setScalar(1 + t * 1.4);
|
||
}
|
||
}
|
||
|
||
// --- Vulkan-Schutzwall (citizen-mountain-Event, wenn gewählt) ---
|
||
let mountainShield = null;
|
||
function buildMountainShield() {
|
||
const g = new THREE.Group();
|
||
g.userData.infoKey = 'shield_info';
|
||
const segments = 9;
|
||
const ringR = VOLCANO_BASE_RADIUS * 0.95;
|
||
for (let i = 0; i < segments; i++) {
|
||
// Halbring auf der Vorder-Insel-Seite (zum Dorf gerichtet, also +Z vom Vulkan)
|
||
const a = Math.PI * 0.15 + (i / (segments - 1)) * Math.PI * 0.7;
|
||
const bx = VOLCANO_X + Math.cos(a) * ringR;
|
||
const bz = VOLCANO_Z + Math.sin(a) * ringR;
|
||
const by = Math.max(terrainHeight(bx, bz), 0) + 0.3;
|
||
const block = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.1, 0.55, 0.4),
|
||
new THREE.MeshStandardMaterial({ color: 0x707577, roughness: 1 })
|
||
);
|
||
block.position.set(bx, by, bz);
|
||
block.rotation.y = a + Math.PI / 2; // Längsseite parallel zum Ring
|
||
g.add(block);
|
||
}
|
||
return g;
|
||
}
|
||
function syncMountainShield() {
|
||
const want = !!state.hasMountainShield;
|
||
if (want && !mountainShield) {
|
||
mountainShield = buildMountainShield();
|
||
scene.add(mountainShield);
|
||
} else if (!want && mountainShield) {
|
||
scene.remove(mountainShield);
|
||
mountainShield = null;
|
||
}
|
||
}
|
||
|
||
// --- Bürger-Event „Strand künstlich aufschütten" (citizen-tourism) ---
|
||
// Setzt state.hasSandBeach = true. Wir zeigen dann vorne an der Insel eine
|
||
// angehobene Strand-Schwelle als breiten, flachen Sand-Streifen parallel
|
||
// zur Küste. Das ist kein einzelnes Mesh pro Kauf, sondern eine einmalige
|
||
// Stadt-Investition (daher separat vom makeSandFill der Bau-Maßnahme).
|
||
let citizenBeach = null;
|
||
function buildCitizenBeach() {
|
||
const g = new THREE.Group();
|
||
g.userData.infoKey = 'sand_fill_info';
|
||
const sandMat = new THREE.MeshStandardMaterial({ color: 0xf0d890, roughness: 1 });
|
||
// Flacher, länglicher Strandwall vorne am Ufer
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-3.5, -0.45);
|
||
shape.lineTo( 3.5, -0.45);
|
||
shape.lineTo( 3.9, -0.25);
|
||
shape.lineTo( 3.9, 0.25);
|
||
shape.lineTo( 3.5, 0.45);
|
||
shape.lineTo(-3.5, 0.45);
|
||
shape.lineTo(-3.9, 0.25);
|
||
shape.lineTo(-3.9, -0.25);
|
||
shape.closePath();
|
||
const geo = new THREE.ExtrudeGeometry(shape, { depth: 0.28, bevelEnabled: false });
|
||
geo.rotateX(-Math.PI / 2);
|
||
// Leichte sin-Wellen oben auf dem Deckel
|
||
const pp = geo.attributes.position;
|
||
for (let i = 0; i < pp.count; i++) {
|
||
const x = pp.getX(i), y = pp.getY(i), z = pp.getZ(i);
|
||
if (y > 0.26) pp.setY(i, y + Math.sin(x * 1.3) * 0.02 + Math.cos(z * 2.0) * 0.015);
|
||
}
|
||
geo.computeVertexNormals();
|
||
const strand = new THREE.Mesh(geo, sandMat);
|
||
// An den vorderen Küstenrand pflanzen, parallel zur Küste dort (+Z-Seite)
|
||
strand.position.set(0, Math.max(terrainHeight(0, ISLAND_HALF_LENGTH * 0.86), 0), ISLAND_HALF_LENGTH * 0.86);
|
||
// Tangente an Ellipse vorn (x=0, z=+ISLAND_HALF_LENGTH*0.86) ist entlang ±X
|
||
strand.rotation.y = coastRotation(0, ISLAND_HALF_LENGTH * 0.86);
|
||
g.add(strand);
|
||
return g;
|
||
}
|
||
function syncCitizenBeach() {
|
||
const want = !!state.hasSandBeach;
|
||
if (want && !citizenBeach) {
|
||
citizenBeach = buildCitizenBeach();
|
||
scene.add(citizenBeach);
|
||
} else if (!want && citizenBeach) {
|
||
scene.remove(citizenBeach);
|
||
citizenBeach = null;
|
||
}
|
||
}
|
||
|
||
// --- Wolken am Himmel ---
|
||
// Ziehen als Dunst-Gruppen quer über die Insel, eher Richtung Vulkan
|
||
// (z-Drift nach hinten). Low-Poly-Sphären, transluzent, ohne Schatten —
|
||
// pro Wolke eigener Zyklus und eigenes Höhenlevel.
|
||
function makeCloud() {
|
||
const g = new THREE.Group();
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
color: 0xffffff, transparent: true, opacity: 0.72, depthWrite: false
|
||
});
|
||
// Drei bis fünf gestapelte Kugeln für Dunst-Silhouette
|
||
const bumps = 3 + Math.floor(Math.random() * 3);
|
||
for (let i = 0; i < bumps; i++) {
|
||
const r = 0.9 + Math.random() * 0.9;
|
||
const sphere = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), mat);
|
||
sphere.position.set(
|
||
(i - bumps / 2) * 1.1 + (Math.random() - 0.5) * 0.4,
|
||
(Math.random() - 0.5) * 0.4,
|
||
(Math.random() - 0.5) * 0.4
|
||
);
|
||
g.add(sphere);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
// Vier Wolken, jede mit eigener Phase, Höhe und Flugdauer.
|
||
const CLOUD_CYCLE = 60; // Sekunden pro Überquerung (Basis)
|
||
const clouds = [];
|
||
for (let i = 0; i < 4; i++) {
|
||
const c = makeCloud();
|
||
c.userData.phase = Math.random(); // 0..1 Startverschiebung
|
||
// Höhe nahe Vulkan-Spitze (Terrain ≈ 5.2 + Dome 2.1 ≈ 7.3) — Wolken
|
||
// ziehen knapp über die Gletscherkuppe, statt hoch am Himmel zu
|
||
// schweben. Fühlbarer "Dunst um den Vulkan"-Look.
|
||
c.userData.height = 6.5 + Math.random() * 1.8;
|
||
c.userData.zStart = 4 + Math.random() * 6; // Insel-Vorder-Bereich
|
||
c.userData.zEnd = -16 - Math.random() * 4; // hinterm Vulkan
|
||
c.userData.scale = 0.75 + Math.random() * 0.45;
|
||
c.userData.speed = 0.8 + Math.random() * 0.6;
|
||
c.scale.setScalar(c.userData.scale);
|
||
ambientGroup.add(c);
|
||
clouds.push(c);
|
||
}
|
||
|
||
function updateClouds(tNow) {
|
||
for (const c of clouds) {
|
||
const cycle = CLOUD_CYCLE / c.userData.speed;
|
||
const u = ((tNow / cycle) + c.userData.phase) % 1; // 0..1 entlang der Bahn
|
||
// Von +X nach -X, Z interpoliert von zStart zu zEnd (Drift Richtung Vulkan)
|
||
c.position.set(
|
||
50 - u * 100,
|
||
c.userData.height,
|
||
c.userData.zStart + (c.userData.zEnd - c.userData.zStart) * u
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Gefällte Bäume (Blackout-Animation) ---
|
||
// Statt des harten Verschwindens beim Stromausfall kippt der Baum langsam
|
||
// um, ein Totenkopf erscheint kurz darauf über ihm, danach fadet alles
|
||
// weg. Die Animations-Zeit hängt an state.animMs → Pause friert ein.
|
||
const fallenTrees = []; // [{ mesh, mats, age, skull, skullMat }]
|
||
let _skullTextureCache = null;
|
||
function getSkullTexture() {
|
||
if (_skullTextureCache) return _skullTextureCache;
|
||
const size = 128;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = canvas.height = size;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, size, size);
|
||
ctx.font = 'bold 96px "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('☠️', size / 2, size / 2 + 4);
|
||
_skullTextureCache = new THREE.CanvasTexture(canvas);
|
||
_skullTextureCache.needsUpdate = true;
|
||
return _skullTextureCache;
|
||
}
|
||
|
||
/** Zieht einen Baum-Mesh aus `placedMeshes` heraus und startet die
|
||
* Fell-Animation. Der Mesh bleibt in `measuresGroup` hängen, wird aber
|
||
* nicht mehr vom nächsten `syncMeasureMeshes`-Durchlauf angefasst. */
|
||
function startTreeFall(mesh) {
|
||
const mats = [];
|
||
mesh.traverse((obj) => {
|
||
if (obj.isMesh && obj.material) {
|
||
const clone = obj.material.clone();
|
||
clone.transparent = true;
|
||
obj.material = clone;
|
||
mats.push(clone);
|
||
}
|
||
});
|
||
fallenTrees.push({ mesh, mats, age: 0, skull: null, skullMat: null });
|
||
}
|
||
|
||
function updateFallenTrees(dt) {
|
||
const FALL_DUR = 1.4;
|
||
const SKULL_DELAY = 1.8;
|
||
const SKULL_FADE = 0.4;
|
||
const FADE_DELAY = 3.2;
|
||
const FADE_DUR = 2.0;
|
||
for (let i = fallenTrees.length - 1; i >= 0; i--) {
|
||
const f = fallenTrees[i];
|
||
f.age += dt;
|
||
// Phase 1: Baum kippt zur Seite (easeOutQuad)
|
||
const fallT = Math.min(1, f.age / FALL_DUR);
|
||
f.mesh.rotation.z = (1 - Math.pow(1 - fallT, 2)) * (-Math.PI / 2.05);
|
||
// Phase 2: Totenkopf spawnt verzögert, fadet sanft ein
|
||
if (!f.skull && f.age >= SKULL_DELAY) {
|
||
const skullMat = new THREE.MeshBasicMaterial({
|
||
map: getSkullTexture(), transparent: true, opacity: 0,
|
||
side: THREE.DoubleSide, depthWrite: false, depthTest: false,
|
||
});
|
||
const skull = new THREE.Mesh(new THREE.PlaneGeometry(0.6, 0.6), skullMat);
|
||
skull.position.set(
|
||
f.mesh.position.x,
|
||
f.mesh.position.y + 0.9,
|
||
f.mesh.position.z
|
||
);
|
||
skull.renderOrder = 10;
|
||
scene.add(skull);
|
||
f.skull = skull;
|
||
f.skullMat = skullMat;
|
||
}
|
||
if (f.skullMat) {
|
||
// Skull schaut immer zur Kamera (Billboard)
|
||
f.skull.quaternion.copy(camera.quaternion);
|
||
const t1 = Math.max(0, Math.min(1, (f.age - SKULL_DELAY) / SKULL_FADE));
|
||
if (f.age < FADE_DELAY) f.skullMat.opacity = t1 * 0.92;
|
||
}
|
||
// Phase 3: Baum + Skull faden raus
|
||
if (f.age > FADE_DELAY) {
|
||
const fadeT = Math.min(1, (f.age - FADE_DELAY) / FADE_DUR);
|
||
const op = 1 - fadeT;
|
||
for (const m of f.mats) m.opacity = op;
|
||
if (f.skullMat) f.skullMat.opacity = op * 0.92;
|
||
if (f.age > FADE_DELAY + FADE_DUR + 0.05) {
|
||
measuresGroup.remove(f.mesh);
|
||
for (const m of f.mats) m.dispose && m.dispose();
|
||
if (f.skull) { scene.remove(f.skull); f.skullMat.dispose(); }
|
||
fallenTrees.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Wird aus simulateTick gerufen, wenn die Engine `felled` Bäume gepopped
|
||
* hat. Findet die jetzt überzähligen Forest-Meshes (Index ≥ currCount),
|
||
* zieht sie aus placedMeshes raus und übergibt sie der Fall-Animation.
|
||
* Zeigt eine Toast-Card pro Tick, nicht pro Baum (sonst spam). */
|
||
function handleTreesFelled(count, currCount) {
|
||
for (const [key, mesh] of Array.from(placedMeshes)) {
|
||
if (!key.startsWith('forest-')) continue;
|
||
const idx = parseInt(key.slice(7), 10);
|
||
if (idx < currCount) continue;
|
||
placedMeshes.delete(key);
|
||
startTreeFall(mesh);
|
||
}
|
||
const msg = count > 1
|
||
? 'Zu wenig Strom! Die Bewohner:innen fällen ' + count + ' Bäume zum Heizen.'
|
||
: 'Zu wenig Strom! Die Bewohner:innen fällen einen Baum zum Heizen.';
|
||
showEvent('🪓', msg, 'bad', 'blackout');
|
||
}
|
||
|
||
// --- Flut-Tönung ---
|
||
// Sobald der Ozean über einem Mesh-Fußpunkt steht, lerpen wir dessen
|
||
// Material-Farben Richtung Dunkelgrau (defekter, schwarzer Look) und
|
||
// kippen das Mesh leicht nach hinten (collapsing). Die Original-Farben
|
||
// werden lazy beim ersten Flood-Check in userData.origColors gecacht.
|
||
const FLOOD_DEEP = 0.55; // Welt-Einheiten unter Wasserlinie für volle Tönung
|
||
const FLOOD_DARK = { r: 0.12, g: 0.12, b: 0.12 };
|
||
|
||
function cacheOriginalColors(mesh) {
|
||
if (mesh.userData.origColors) return;
|
||
const cache = [];
|
||
mesh.traverse((obj) => {
|
||
if (obj.isMesh && obj.material && obj.material.color) {
|
||
cache.push({ mat: obj.material, c: obj.material.color.clone() });
|
||
}
|
||
});
|
||
mesh.userData.origColors = cache;
|
||
mesh.userData.origRotX = mesh.rotation.x || 0;
|
||
}
|
||
|
||
function tintMeshFlood(mesh, floodT) {
|
||
cacheOriginalColors(mesh);
|
||
for (const { mat, c } of mesh.userData.origColors) {
|
||
mat.color.setRGB(
|
||
c.r + (FLOOD_DARK.r - c.r) * floodT,
|
||
c.g + (FLOOD_DARK.g - c.g) * floodT,
|
||
c.b + (FLOOD_DARK.b - c.b) * floodT
|
||
);
|
||
}
|
||
// Leichter Kipp nach hinten, bis −0.12 rad (≈ 7°)
|
||
mesh.rotation.x = mesh.userData.origRotX - floodT * 0.12;
|
||
}
|
||
|
||
function applyFloodTintAll() {
|
||
const waterY = ocean.position.y;
|
||
const targets = [];
|
||
for (const mesh of placedMeshes.values()) targets.push(mesh);
|
||
for (const house of villageHouses) if (house.visible) targets.push(house);
|
||
if (citizenBeach) targets.push(citizenBeach);
|
||
for (const m of targets) {
|
||
const footY = m.position.y;
|
||
const floodT = Math.max(0, Math.min(1, (waterY - footY) / FLOOD_DEEP));
|
||
tintMeshFlood(m, floodT);
|
||
}
|
||
}
|
||
|
||
// Zentrale Ambient-Update-Funktion (aus sceneRender pro Frame)
|
||
function updateAmbient(tNow, dt) {
|
||
updateBoats(tNow);
|
||
updateAirportVehicles(tNow);
|
||
updateVillage(tNow, dt);
|
||
updateClouds(tNow);
|
||
updateFallenTrees(dt);
|
||
}
|
||
|
||
/* ============================================================
|
||
TICK LOOP
|
||
============================================================ */
|
||
|
||
// Akkumulierte Spielzeit (Millisekunden), die AUSSCHLIESSLICH läuft, wenn
|
||
// das Spiel nicht pausiert ist (Speed > 0, Phase = playing). Sämtliche
|
||
// Szenen-Animationen (Windräder, Ozean-Wellen, Auto-Drohne) 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();
|
||
}
|
||
}
|
||
// Szene jederzeit rendern (bei Pause steht animMs, sodass spätere
|
||
// Szenen-Animationen einfrieren). Level-Select und Loading haben noch
|
||
// keinen State-Hintergrund, die Szene zeigt dann die Leer-Insel.
|
||
sceneRender();
|
||
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; }
|
||
updatePauseCenter();
|
||
});
|
||
});
|
||
|
||
// Pause-Zeichen zentral in der Szene: sichtbar bei speed=0, sonst aus.
|
||
// Klick entspricht dem Druck auf den 1×-Speed-Button.
|
||
const pauseCenter = $('#pause-center');
|
||
function updatePauseCenter() {
|
||
if (!pauseCenter) return;
|
||
const visible = state && state.speed === 0
|
||
&& (state.phase === 'playing' || state.phase === 'paused' || state.phase === 'tutorial');
|
||
pauseCenter.style.display = visible ? '' : 'none';
|
||
}
|
||
if (pauseCenter) {
|
||
pauseCenter.addEventListener('click', () => {
|
||
const btn = document.querySelector('[data-speed="1"]');
|
||
if (btn) btn.click();
|
||
});
|
||
}
|
||
|
||
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
|
||
============================================================ */
|
||
|
||
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,
|
||
});
|
||
_wasBudgetCritical = false;
|
||
_wasTempCritical = false;
|
||
_wasFloodCritical = false;
|
||
|
||
// --- Start-Wald: drei Bäume als Anfangsbestand. Bei Stromausfall
|
||
// fällt die Engine pro Jahr `treesPerBlackout` Bäume (engine.js L611+),
|
||
// popt sie aus instances und reduziert count — die Meshes
|
||
// verschwinden entsprechend aus der Szene (syncMeasureMeshes).
|
||
// Die drei Start-Bäume sind nicht im actionLog (kein Player-Kauf),
|
||
// zählen aber beim `forester`-Achievement mit.
|
||
const forestSeed = { count: 0, instances: [] };
|
||
for (let i = 0; i < 3; i++) {
|
||
const meta = pickBuildPosition('forest');
|
||
forestSeed.instances.push({ builtAt: 0, starter: true, ...meta });
|
||
forestSeed.count++;
|
||
}
|
||
state.ownedMeasures.forest = forestSeed;
|
||
KlimaEngine.recalcEffects(state); // co2Reduction etc. mit Seed-Wald neu aufsummieren
|
||
|
||
// --- 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;
|
||
// Alle Platzhalter-Meshes vom alten Spielstand löschen, bevor die neue
|
||
// Instanz gerendert wird — frisches Level = leere Insel.
|
||
for (const mesh of placedMeshes.values()) measuresGroup.remove(mesh);
|
||
placedMeshes.clear();
|
||
if (placementId) cancelPlacement();
|
||
// Start-Dorf aufstellen + Vulkan-Schutzwall zurücksetzen (Engine hatte
|
||
// hasMountainShield in createGame nicht gesetzt → false/undefined).
|
||
buildVillage();
|
||
syncMountainShield();
|
||
syncCitizenBeach();
|
||
syncMeasureMeshes(); // die drei Start-Bäume sichtbar machen
|
||
renderUI();
|
||
startTutorial();
|
||
// Hinweis-Card: erklärt die Start-Bäume + Blackout-Mechanik. infoKey
|
||
// 'blackout' macht sie klickbar → INFO_TOPICS.blackout-Detailseite.
|
||
setTimeout(() => {
|
||
showEvent('🌲', 'Du startest mit 3 Bäumen. Bei Stromausfall werden sie als Brennholz gefällt — baue rechtzeitig Wind oder Solar.', 'neutral', 'blackout');
|
||
}, 900);
|
||
}
|
||
|
||
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,
|
||
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 < 5 && state.population > state.diff.startPopulation * 0.9) stars = Math.min(5, stars+1);
|
||
return Math.max(0, Math.min(5, stars));
|
||
}
|
||
function computeScore() {
|
||
const tempScore = Math.max(0, Math.min(35, 35 - (state.currentTemp - 15.5) * 20));
|
||
const floodScore = Math.max(0, 25 - state.floodedPct * 0.8);
|
||
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 (< 30 % überflutet)', achieved: state.floodedPct < 30 },
|
||
];
|
||
}
|
||
|
||
function showEndScreen(stars, score, reason) {
|
||
const won = state.levelWon;
|
||
const goals = evalGoals();
|
||
const achievedCount = goals.filter(g => g.achieved).length;
|
||
const totalCount = goals.length;
|
||
|
||
const icon = won ? '🏆'
|
||
: reason === 'pleite' ? '💸'
|
||
: reason === 'ueberflutet' ? '🌊'
|
||
: reason === 'co2-top' ? '🌫'
|
||
: reason === 'temp-top' ? '🌡'
|
||
: '⚠️';
|
||
const title = won ? 'Ziel erreicht!'
|
||
: reason === 'pleite' ? 'Budget aufgebraucht'
|
||
: reason === 'ueberflutet' ? 'Küste überflutet'
|
||
: reason === 'co2-top' ? 'CO₂-Gefahrenzone erreicht'
|
||
: reason === 'temp-top' ? 'Klima kippt — zu heiß'
|
||
: 'Ziel nicht erreicht';
|
||
$('#end-icon').textContent = icon;
|
||
$('#end-title').textContent = title;
|
||
$('#end-subtitle').textContent = won
|
||
? ('Du hast bis ' + (state.startYear + state.tick) + ' alle ' + totalCount + ' Hauptziele erreicht. Hervorragende Leistung!')
|
||
: ('Im Jahr ' + (state.startYear + state.tick) + ' ist der Durchgang beendet — ' + 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-3d';
|
||
|
||
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,
|
||
});
|
||
// Alte 3D-Meshes löschen, neue aus dem geladenen State aufbauen.
|
||
// Sicherheitsnetz für alte/foreign Saves ohne pos: syncMeasureMeshes
|
||
// weist in pickBuildPosition-Fallback einen Ersatz zu, sodass fehlende
|
||
// Positionen kein Mesh-Abriss auslösen.
|
||
for (const mesh of placedMeshes.values()) measuresGroup.remove(mesh);
|
||
placedMeshes.clear();
|
||
if (placementId) cancelPlacement();
|
||
// Instanzen ohne pos: Fallback nachreichen (z.B. Saves aus Phase A)
|
||
for (const id in state.ownedMeasures) {
|
||
const o = state.ownedMeasures[id];
|
||
if (!o || !o.instances) continue;
|
||
for (const inst of o.instances) {
|
||
if (!inst.pos || typeof inst.pos.y !== 'number') {
|
||
const meta = pickBuildPosition(id);
|
||
inst.pos = meta.pos;
|
||
if (typeof inst.rotation !== 'number') inst.rotation = meta.rotation;
|
||
if (!inst.zoneId) inst.zoneId = meta.zoneId;
|
||
}
|
||
}
|
||
}
|
||
syncMeasureMeshes();
|
||
buildVillage(); // Dorf passend zur geladenen Bevölkerung
|
||
syncMountainShield(); // Schutzwall-Flag aus Save übernehmen
|
||
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() {
|
||
// 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;
|
||
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('💾', 'Arbeitsstand geladen — fortsetzen 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();
|
||
}
|
||
|
||
/**
|
||
* startFreshSimulation — vom End-Screen „Neue Simulation". Räumt den Save
|
||
* des aktuellen Levels weg und startet das gleiche Level nochmal, ohne die
|
||
* Seite neu zu laden (sonst gingen Szene, Musik und Audio-Kontext verloren).
|
||
*/
|
||
function startFreshSimulation() {
|
||
const lvl = (state && state.difficulty) || 1;
|
||
localStorage.removeItem(SAVE_KEY_BASE + '-' + lvl);
|
||
hideOverlay('end-overlay');
|
||
hideOverlay('level-overlay');
|
||
startGame(lvl);
|
||
}
|
||
|
||
$('#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';
|
||
const GLOSSAR_PAGE = (window.__GGS__ && window.__GGS__.basePath ? 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;
|
||
}
|
||
|
||
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 — öffnet in neuem Tab
|
||
html += '<br><a href="'+GLOSSAR_PAGE+'?term='+encodeURIComponent(key)+'" target="_blank" rel="noopener" class="ggs-glossar-link">Voller Glossar-Eintrag →</a>';
|
||
if (entry.link && !entry.fromApi) {
|
||
html += ' <a href="'+entry.link+'" target="_blank" rel="noopener" class="ggs-glossar-link" 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();
|
||
onSceneResize();
|
||
sceneRender();
|
||
|
||
// 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('');
|
||
if (state.track) 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;
|
||
};
|
||
|
||
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>
|