f22c5ebbfe
- PHP/MySQL Backend (XAMPP + Produktionsserver) - Front-Controller, API-Endpunkte, Session-Management - Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss) - Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem) - Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst - persistence.ts: Dualer Speicher (localStorage + Server-API) - 6 Unit-Test-Dateien fuer bestehende Simulationen Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1240 lines
50 KiB
HTML
1240 lines
50 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/png" href="assets/img/logo.png">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||
<style>
|
||
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
||
:root {
|
||
--bg: #f7f6f3;
|
||
--text: #1a1a1a; --text2: #4a4a4a; --text3: #8a8a8a;
|
||
--fjord: #4a7c8a; --fjord-l: #dae8ec; --fjord-d: #3a6470;
|
||
--moss: #5a8a5e; --moss-l: #dceadd;
|
||
--sand: #c4a35a; --sand-l: #f2eacc;
|
||
--coral: #c07a6b; --coral-l: #f2ddd8;
|
||
--danger: #b04a3a;
|
||
|
||
--glass-bg: rgba(255,255,255,.80);
|
||
--glass-bg2: rgba(255,255,255,.90);
|
||
--glass-bord: rgba(255,255,255,.55);
|
||
--glass-shadow: 0 8px 32px rgba(20,40,60,.18);
|
||
}
|
||
html, body { height:100%; overflow:hidden; }
|
||
body { font-family:'Inter',system-ui,sans-serif; background:#bcd5e6; color:var(--text); line-height:1.5; }
|
||
|
||
/* === FULLSCREEN CANVAS === */
|
||
#canvas-wrap { position:fixed; inset:0; z-index:0; background:#bcd5e6; }
|
||
#canvas-wrap canvas { width:100% !important; height:100% !important; display:block; }
|
||
|
||
/* === FLOATING PANELS === */
|
||
.floating {
|
||
position:fixed; z-index:5;
|
||
backdrop-filter:blur(14px) saturate(140%); -webkit-backdrop-filter:blur(14px) saturate(140%);
|
||
background:var(--glass-bg); border:1px solid var(--glass-bord);
|
||
box-shadow:var(--glass-shadow); border-radius:14px;
|
||
}
|
||
.floating-strong { background:var(--glass-bg2); }
|
||
|
||
/* === TOP BAR — durchgehende Leiste oben === */
|
||
.topbar {
|
||
top: 0; left: 0; right: 0;
|
||
padding: .35rem .7rem;
|
||
display: flex; justify-content: space-between; align-items: center; gap: .8rem;
|
||
border-radius: 0; /* keine Card mehr */
|
||
border-left: none; border-right: none; border-top: none;
|
||
border-bottom: 1px solid rgba(0,0,0,.08);
|
||
}
|
||
.topbar .brand {
|
||
display:flex; align-items:center; gap:.5rem; font-weight:800; font-size:.92rem;
|
||
color:var(--fjord); text-decoration:none; white-space:nowrap;
|
||
}
|
||
.topbar .brand img { width:24px; height:24px; border-radius:6px; }
|
||
.topbar .brand .badge-3d {
|
||
background:linear-gradient(135deg,#4a7c8a,#6aa8b8); color:#fff;
|
||
font-size:.58rem; padding:2px 6px; border-radius:4px;
|
||
font-weight:800; letter-spacing:.04em;
|
||
}
|
||
|
||
.topbar-controls { display:flex; gap:.5rem; align-items:center; }
|
||
|
||
/* Icon-only Buttons im Header */
|
||
.icon-btn, .nav-btn {
|
||
width:32px; height:32px; border-radius:8px;
|
||
border:1px solid rgba(0,0,0,.08); background:rgba(255,255,255,.55);
|
||
color:var(--text2); font-size:.95rem; cursor:pointer;
|
||
display:flex; align-items:center; justify-content:center; padding:0;
|
||
text-decoration:none; transition:all .15s;
|
||
}
|
||
.icon-btn:hover, .nav-btn:hover {
|
||
background:var(--fjord-l); border-color:var(--fjord); color:var(--fjord);
|
||
}
|
||
.ctrl-group {
|
||
display:flex; gap:.2rem; padding:.2rem;
|
||
background:rgba(255,255,255,.45); border-radius:9px;
|
||
border:1px solid rgba(0,0,0,.06);
|
||
}
|
||
.ctrl-group .icon-btn {
|
||
border:none; background:transparent;
|
||
width:30px; height:30px; font-size:.8rem; font-weight:700;
|
||
}
|
||
.ctrl-group .icon-btn:hover { background:rgba(0,0,0,.06); }
|
||
.ctrl-group .icon-btn.active {
|
||
background:var(--fjord); color:#fff;
|
||
}
|
||
.speed-label { font-size:.55rem; font-weight:800; opacity:.85; }
|
||
|
||
.topbar-sep {
|
||
width:1px; height:24px; background:rgba(0,0,0,.08);
|
||
}
|
||
|
||
/* === PANELS — schmaler als vorher === */
|
||
.panel-h {
|
||
font-size:.58rem; font-weight:800; color:var(--fjord);
|
||
text-transform:uppercase; letter-spacing:.07em; margin-bottom:.4rem;
|
||
}
|
||
|
||
/*
|
||
* === LAYOUT-KONZEPT ===
|
||
* Drei feste Bereiche, alle mit denselben Abständen:
|
||
* 1. Topbar oben (durchgehende Leiste, kein Abstand)
|
||
* 2. Graphen-Leiste unten quer, volle Breite (--graphs-h)
|
||
* 3. Seitenpanels rechts und links DAZWISCHEN
|
||
*
|
||
* Jede Spalte hat 2 Panels (oben/unten) mit klarem Trennabstand --gap.
|
||
*/
|
||
:root {
|
||
--topbar-h: 2.8rem; /* Höhe der Topbar-Leiste */
|
||
--graphs-h: 23vh; /* Höhe der Graphen-Leiste */
|
||
--side-w: 210px; /* Breite der linken Status-Spalte */
|
||
--shop-w: 270px; /* Breite der rechten Maßnahmen-Spalte (breiter) */
|
||
--pad: .65rem; /* Außenabstand */
|
||
--gap: .55rem; /* Abstand zwischen den 2 Panels einer Spalte */
|
||
}
|
||
|
||
/* LINKS: eine durchgehende Spalte mit Status + Ziele + Ereignisse */
|
||
.panel-status {
|
||
top: calc(var(--topbar-h) + var(--pad));
|
||
bottom: calc(var(--graphs-h) + var(--pad) + var(--pad));
|
||
left: var(--pad);
|
||
width: var(--side-w);
|
||
padding: .6rem .75rem;
|
||
overflow-y: auto;
|
||
}
|
||
/* Zweite/dritte Überschrift in der Status-Box (Ziele, Ereignisse) */
|
||
.panel-h-sub {
|
||
margin-top: .9rem;
|
||
padding-top: .6rem;
|
||
border-top: 1px solid rgba(0,0,0,.08);
|
||
}
|
||
/* RECHTS: eine durchgehende Spalte für Maßnahmen, breiter */
|
||
.panel-shop {
|
||
top: calc(var(--topbar-h) + var(--pad));
|
||
bottom: calc(var(--graphs-h) + var(--pad) + var(--pad));
|
||
right: var(--pad);
|
||
width: var(--shop-w);
|
||
padding: .6rem .75rem;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* === GRAPHEN-LEISTE — unten quer, volle Breite === */
|
||
.panel-graphs {
|
||
bottom: var(--pad);
|
||
left: var(--pad);
|
||
right: var(--pad);
|
||
height: var(--graphs-h);
|
||
padding: .5rem .65rem;
|
||
}
|
||
.graphs-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: .6rem;
|
||
height: 100%;
|
||
}
|
||
.graph-card {
|
||
background: rgba(255,255,255,.62);
|
||
border-radius: 9px;
|
||
padding: .35rem .55rem .35rem;
|
||
border: 1px solid rgba(0,0,0,.05);
|
||
display: flex; flex-direction: column;
|
||
min-height: 0; min-width: 0;
|
||
}
|
||
.graph-title {
|
||
font-size: .62rem; color: var(--text3); font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: .05em;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: .15rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.graph-info-btn {
|
||
width: 14px; height: 14px; border-radius: 50%; border: none;
|
||
background: rgba(0,0,0,.08); color: var(--text2);
|
||
font-size: .58rem; font-weight: 800; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.graph-info-btn:hover { background: var(--fjord); color: #fff; }
|
||
.graph-svg {
|
||
width: 100%; flex: 1; min-height: 0; display: block;
|
||
}
|
||
/* === GRAPH-CLICK-ZOOM === */
|
||
.graph-card { cursor: zoom-in; }
|
||
.graph-card.zoomed {
|
||
cursor: zoom-out;
|
||
box-shadow: 0 30px 80px rgba(0,0,0,.45);
|
||
background: rgba(255,255,255,.97);
|
||
backdrop-filter: blur(20px) saturate(140%);
|
||
border: 1px solid rgba(255,255,255,.6);
|
||
padding: 1.1rem 1.4rem;
|
||
}
|
||
/* Im Zoom Title etwas größer (war .62rem), aber NICHT 1rem */
|
||
.graph-card.zoomed .graph-title { font-size: .78rem; margin-bottom: .55rem; }
|
||
.graph-card.zoomed .unit-chip { font-size: .9em; }
|
||
.graph-zoom-backdrop {
|
||
position: fixed; inset: 0; z-index: 140;
|
||
background: rgba(15,30,40,.55); backdrop-filter: blur(6px);
|
||
opacity: 0; transition: opacity .35s ease-out;
|
||
pointer-events: none;
|
||
}
|
||
.graph-zoom-backdrop.active {
|
||
opacity: 1; pointer-events: auto;
|
||
}
|
||
|
||
/* Einheiten-Chip im Graph-Titel mit Tooltip */
|
||
.unit-chip {
|
||
display: inline-flex; align-items: center; gap: .22em;
|
||
background: rgba(0,0,0,.06); color: var(--text2);
|
||
padding: 1px 6px 1px 6px; border-radius: 5px;
|
||
text-transform: none; letter-spacing: 0;
|
||
font-size: .88em; font-weight: 700; cursor: help;
|
||
margin-left: .15em;
|
||
transition: background .12s;
|
||
}
|
||
.unit-chip:hover { background: var(--fjord-l); color: var(--fjord-d); }
|
||
.unit-chip .unit-i {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 11px; height: 11px; border-radius: 50%;
|
||
background: rgba(0,0,0,.28); color: #fff;
|
||
font-size: .56em; font-weight: 900;
|
||
font-style: italic;
|
||
}
|
||
.unit-chip:hover .unit-i { background: var(--fjord); }
|
||
|
||
/* === STATUS (LEFT) === */
|
||
.resource {
|
||
display:flex; align-items:flex-start; gap:.5rem; padding:.3rem .1rem;
|
||
border-bottom:1px solid rgba(0,0,0,.06);
|
||
}
|
||
.resource:last-child { border-bottom:none; }
|
||
.resource-icon { font-size:1.15rem; width:22px; text-align:center; padding-top:.1rem; }
|
||
.resource-info { flex:1; min-width:0; }
|
||
.resource-name { font-size:.58rem; color:var(--text3); font-weight:500; line-height:1.1; }
|
||
.resource-val { font-size:.85rem; font-weight:800; color:var(--text); line-height:1.2; }
|
||
|
||
/* === STROM-NETZ-VISUALISIERUNG === */
|
||
.power-grid {
|
||
margin-top:.35rem;
|
||
padding:.4rem .45rem;
|
||
background:rgba(0,0,0,.04);
|
||
border-radius:7px;
|
||
display:flex; flex-direction:column; gap:.28rem;
|
||
}
|
||
.pg-row { display:flex; align-items:center; gap:.35rem; }
|
||
.pg-lbl {
|
||
font-size:.56rem; font-weight:700;
|
||
color:var(--text3); white-space:nowrap; min-width:48px;
|
||
}
|
||
.pg-track {
|
||
flex:1; height:8px;
|
||
background:rgba(0,0,0,.10);
|
||
border-radius:4px; overflow:hidden;
|
||
position:relative;
|
||
}
|
||
.pg-fill {
|
||
height:100%; border-radius:4px;
|
||
transition:width .35s ease-out, background .25s;
|
||
}
|
||
.pg-cap { background:linear-gradient(90deg, #5a8a5e, #6aa86e); }
|
||
.pg-cap-low { background:linear-gradient(90deg, #b04a3a, #c07a6b); }
|
||
.pg-dem { background:linear-gradient(90deg, #4a7c8a, #6aa8b8); }
|
||
.pg-num {
|
||
font-size:.6rem; font-weight:800; color:var(--text);
|
||
min-width:36px; text-align:right;
|
||
}
|
||
.pg-status {
|
||
font-size:.56rem; font-weight:700;
|
||
text-align:center; padding:.18rem 0 0;
|
||
letter-spacing:.02em;
|
||
}
|
||
.pg-status-ok { color:var(--moss); }
|
||
.pg-status-bad { color:var(--danger); animation:pg-pulse 1.4s ease-in-out infinite; }
|
||
@keyframes pg-pulse { 0%,100%{opacity:1} 50%{opacity:.55} }
|
||
|
||
/* === BAUMODUS-HINWEIS-BANNER === */
|
||
/* Schwebt mittig oben unter der Topbar, zeigt was gerade gebaut wird
|
||
und wie man den Modus beendet. Sichtbar nur wenn .visible. */
|
||
.build-banner {
|
||
position: fixed;
|
||
top: calc(var(--topbar-h) + var(--pad));
|
||
left: 50%;
|
||
transform: translate(-50%, -12px);
|
||
z-index: 30;
|
||
padding: .55rem 1rem;
|
||
background: rgba(255,255,255,.92);
|
||
backdrop-filter: blur(14px);
|
||
border: 1px solid var(--fjord);
|
||
border-radius: 12px;
|
||
box-shadow: 0 10px 30px rgba(20,40,60,.22);
|
||
display: flex; align-items: center; gap: .6rem;
|
||
font-size: .8rem; font-weight: 700; color: var(--text);
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity .25s ease-out, transform .25s ease-out;
|
||
}
|
||
.build-banner.visible {
|
||
opacity: 1;
|
||
transform: translate(-50%, 0);
|
||
}
|
||
.build-banner .bb-emoji { font-size: 1.2rem; }
|
||
.build-banner .bb-text { line-height: 1.25; }
|
||
.build-banner .bb-text .bb-name { color: var(--fjord); }
|
||
.build-banner .bb-text .bb-hint { font-size: .65rem; font-weight: 600; color: var(--text3); }
|
||
.build-banner kbd {
|
||
display: inline-block; padding: 1px 6px;
|
||
background: rgba(0,0,0,.08); border: 1px solid rgba(0,0,0,.15);
|
||
border-radius: 4px; font-size: .65rem; font-weight: 800;
|
||
font-family: ui-monospace, monospace;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* === DIFFICULTY SELECTOR === */
|
||
.difficulty-overlay {
|
||
position: fixed; inset: 0; z-index: 200;
|
||
background: rgba(15,30,40,.88); backdrop-filter: blur(10px);
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 1.5rem;
|
||
}
|
||
.difficulty-card {
|
||
background: #fff; border-radius: 18px;
|
||
padding: 1.8rem 2rem; max-width: 720px; width: 100%;
|
||
box-shadow: 0 30px 80px rgba(0,0,0,.4);
|
||
}
|
||
.difficulty-card h2 {
|
||
font-size: 1.4rem; font-weight: 800; margin-bottom: .35rem;
|
||
color: var(--fjord); letter-spacing: -.01em;
|
||
}
|
||
.difficulty-card p.subtitle {
|
||
color: var(--text2); font-size: .95rem; margin-bottom: 1.4rem;
|
||
}
|
||
.difficulty-buttons {
|
||
display: grid; grid-template-columns: repeat(3, 1fr); gap: .9rem;
|
||
}
|
||
.difficulty-btn {
|
||
background: #f7f6f3; border: 2px solid rgba(0,0,0,.08);
|
||
border-radius: 14px; padding: 1.1rem 1rem 1rem;
|
||
cursor: pointer; transition: all .15s;
|
||
display: flex; flex-direction: column; align-items: center;
|
||
gap: .5rem; text-align: center; font-family: inherit;
|
||
}
|
||
.difficulty-btn:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 10px 30px rgba(0,0,0,.12);
|
||
}
|
||
.difficulty-btn.lvl-1 { border-color: rgba(90,138,94,.35); }
|
||
.difficulty-btn.lvl-1:hover { border-color: var(--moss); background: var(--moss-l); }
|
||
.difficulty-btn.lvl-2 { border-color: rgba(196,163,90,.35); }
|
||
.difficulty-btn.lvl-2:hover { border-color: var(--sand); background: var(--sand-l); }
|
||
.difficulty-btn.lvl-3 { border-color: rgba(176,74,58,.35); }
|
||
.difficulty-btn.lvl-3:hover { border-color: var(--danger); background: var(--coral-l); }
|
||
.difficulty-emoji { font-size: 2.4rem; line-height: 1; }
|
||
.difficulty-name { font-size: 1.05rem; font-weight: 800; color: var(--text); }
|
||
.difficulty-desc { font-size: .76rem; color: var(--text2); line-height: 1.45; }
|
||
.difficulty-stats {
|
||
font-size: .65rem; color: var(--text3); margin-top: .35rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* === LEVEL BADGE in der Topbar === */
|
||
.level-badge {
|
||
display: flex; align-items: center; gap: .35rem;
|
||
padding: 4px 9px; border-radius: 8px;
|
||
background: rgba(255,255,255,.55); border: 1px solid rgba(0,0,0,.08);
|
||
font-size: .68rem; font-weight: 800; color: var(--text2);
|
||
cursor: pointer; transition: all .12s;
|
||
font-family: inherit;
|
||
}
|
||
.level-badge:hover { background: var(--fjord-l); border-color: var(--fjord); color: var(--fjord); }
|
||
.level-badge .lb-emoji { font-size: .85rem; }
|
||
|
||
/* === BUDGET-BILANZ === */
|
||
.budget-balance {
|
||
margin-top:.35rem;
|
||
padding:.35rem .45rem;
|
||
background:rgba(0,0,0,.04);
|
||
border-radius:7px;
|
||
display:flex; flex-direction:column; gap:.18rem;
|
||
}
|
||
.bb-row {
|
||
display:flex; justify-content:space-between; align-items:center;
|
||
font-size:.6rem; line-height:1.15;
|
||
}
|
||
.bb-lbl { color:var(--text3); font-weight:600; }
|
||
.bb-num { font-weight:800; font-variant-numeric:tabular-nums; }
|
||
.bb-pos { color:var(--moss); }
|
||
.bb-neg { color:var(--danger); }
|
||
.bb-net-row {
|
||
margin-top:.15rem; padding-top:.22rem;
|
||
border-top:1px solid rgba(0,0,0,.10);
|
||
}
|
||
.bb-net-row .bb-lbl { color:var(--text2); font-weight:700; }
|
||
.bb-net { font-size:.68rem; }
|
||
|
||
/* === GOALS === */
|
||
.goal { padding:.3rem 0; border-bottom:1px solid rgba(0,0,0,.06); }
|
||
.goal:last-child { border:0; }
|
||
.goal-row { display:flex; align-items:center; gap:.35rem; font-size:.7rem; }
|
||
.goal-check {
|
||
width:12px; height:12px; border:1.5px solid var(--text3);
|
||
border-radius:3px; display:flex; align-items:center; justify-content:center;
|
||
font-size:.52rem; flex-shrink:0;
|
||
}
|
||
.goal-check.done { background:var(--moss); border-color:var(--moss); color:#fff; }
|
||
.goal-bar { height:3px; background:rgba(0,0,0,.08); border-radius:2px; margin-top:.2rem; overflow:hidden; }
|
||
.goal-bar-fill { height:100%; background:var(--moss); transition:width .3s; }
|
||
|
||
/* === SHOP === */
|
||
.measure-card {
|
||
display:flex; gap:.5rem; padding:.5rem; border-radius:9px;
|
||
border:1px solid rgba(0,0,0,.08); margin-bottom:.35rem; cursor:pointer;
|
||
transition:all .15s; background:rgba(255,255,255,.55);
|
||
}
|
||
.measure-card:hover { border-color:var(--fjord); background:var(--fjord-l); transform:translateX(-2px); }
|
||
.measure-card.disabled { opacity:.45; cursor:not-allowed; }
|
||
.measure-card.disabled:hover { border-color:rgba(0,0,0,.08); background:rgba(255,255,255,.55); transform:none; }
|
||
.measure-icon { font-size:1.25rem; width:26px; text-align:center; }
|
||
.measure-info { flex:1; min-width:0; }
|
||
.measure-name { font-size:.72rem; font-weight:700; }
|
||
.measure-desc { font-size:.58rem; color:var(--text3); line-height:1.3; }
|
||
.measure-stats { display:flex; gap:.22rem; flex-wrap:wrap; margin-top:.18rem; }
|
||
.measure-stat {
|
||
font-size:.54rem; padding:1px 5px; border-radius:4px;
|
||
background:rgba(0,0,0,.05); color:var(--text3); font-weight:600;
|
||
}
|
||
.measure-cost { background:var(--sand-l); color:var(--sand); }
|
||
.measure-reduction { background:var(--moss-l); color:var(--moss); }
|
||
.measure-protection { background:var(--fjord-l); color:var(--fjord); }
|
||
.measure-capacity { background:#e8e3f2; color:#7a5ea8; }
|
||
.measure-quality { background:var(--coral-l); color:var(--danger); }
|
||
.measure-neutral { background:rgba(0,0,0,.05); color:var(--text3); }
|
||
/* Stückzahl-Badge im Maßnahmen-Namen */
|
||
.measure-owned {
|
||
display:inline-block; margin-left:.25rem;
|
||
background:var(--fjord); color:#fff;
|
||
font-size:.55rem; font-weight:800;
|
||
padding:1px 5px; border-radius:8px;
|
||
vertical-align:middle;
|
||
}
|
||
/* Abreißen-Button rechts auf der Karte */
|
||
.measure-card { position:relative; }
|
||
.measure-demolish {
|
||
position:absolute; top:.35rem; right:.35rem;
|
||
width:20px; height:20px; border:none; cursor:pointer;
|
||
background:rgba(0,0,0,.06); border-radius:5px;
|
||
font-size:.72rem; line-height:1;
|
||
display:flex; align-items:center; justify-content:center;
|
||
color:var(--text2); transition:all .12s;
|
||
}
|
||
.measure-demolish:hover { background:var(--coral-l); color:var(--danger); }
|
||
|
||
/* === EVENTS-STRIP (rechts vom Status-Panel, klappt nach oben auf) ===
|
||
Sitzt unten direkt rechts vom Status-Panel, gleiche Breite.
|
||
Default: nur Header + 1 Card (~80px hoch). Klick → max-height wächst
|
||
nach oben, zeigt alle Ereignisse scrollbar. Klick wieder → schließt. */
|
||
.panel-events-strip {
|
||
position: fixed;
|
||
left: calc(var(--side-w) + var(--pad) * 2);
|
||
bottom: calc(var(--graphs-h) + var(--pad) + var(--pad));
|
||
width: var(--side-w);
|
||
max-height: 84px;
|
||
padding: .35rem .55rem .45rem;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: max-height .38s cubic-bezier(.22,1,.36,1),
|
||
box-shadow .25s ease-out;
|
||
z-index: 12;
|
||
display: flex; flex-direction: column; gap: .25rem;
|
||
}
|
||
.panel-events-strip.open {
|
||
max-height: calc(100vh - var(--topbar-h) - var(--graphs-h) - 4 * var(--pad));
|
||
box-shadow: 0 18px 50px rgba(20,40,60,.28);
|
||
cursor: zoom-out;
|
||
}
|
||
.events-strip-header {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
font-size: .58rem; font-weight: 800; color: var(--fjord);
|
||
text-transform: uppercase; letter-spacing: .07em;
|
||
flex-shrink: 0;
|
||
}
|
||
.esh-toggle {
|
||
display: inline-block; transition: transform .35s ease-out;
|
||
font-size: .65rem; color: var(--text3);
|
||
}
|
||
.panel-events-strip.open .esh-toggle { transform: rotate(180deg); }
|
||
.events-strip-list {
|
||
overflow-y: auto;
|
||
flex: 1; min-height: 0;
|
||
}
|
||
/* Im geschlossenen Zustand: Liste zeigt nur das erste (neueste) Element */
|
||
.panel-events-strip:not(.open) .events-strip-list .event:not(:first-child) {
|
||
display: none;
|
||
}
|
||
/* Im geschlossenen Zustand: Card kompakt halten */
|
||
.panel-events-strip:not(.open) .events-strip-list .event {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* === EVENTS === */
|
||
.event {
|
||
padding:.3rem .45rem; border-radius:7px; margin-bottom:.25rem;
|
||
font-size:.68rem; line-height:1.3;
|
||
display:flex; gap:.3rem; align-items:flex-start;
|
||
}
|
||
.event-text { flex:1; }
|
||
.event-info { background:rgba(0,0,0,.05); color:var(--text2); }
|
||
.event-success { background:var(--moss-l); color:var(--moss); }
|
||
.event-warning { background:var(--sand-l); color:var(--sand); }
|
||
.event-danger { background:var(--coral-l); color:var(--danger); }
|
||
.event-info-btn {
|
||
width:13px; height:13px; border-radius:50%; border:none;
|
||
background:rgba(0,0,0,.15); color:#fff; font-size:.55rem;
|
||
cursor:pointer; display:flex; align-items:center; justify-content:center;
|
||
flex-shrink:0; font-weight:800;
|
||
}
|
||
.event-info-btn:hover { background:var(--fjord); }
|
||
|
||
/* === TUTORIAL === */
|
||
.tutorial-overlay {
|
||
position:fixed; inset:0; background:rgba(15,30,40,.85);
|
||
backdrop-filter:blur(8px); display:flex; align-items:center;
|
||
justify-content:center; z-index:50; padding:1.5rem;
|
||
}
|
||
.tutorial-card {
|
||
background:#fff; border-radius:18px; padding:1.6rem 1.9rem;
|
||
max-width:520px; box-shadow:0 30px 80px rgba(0,0,0,.4);
|
||
}
|
||
.tutorial-card h3 { font-size:1.25rem; font-weight:800; margin-bottom:.6rem; color:var(--fjord); }
|
||
.tutorial-card p { font-size:.9rem; color:var(--text2); white-space:pre-line; margin-bottom:1rem; }
|
||
.progress-dots { display:flex; gap:.3rem; justify-content:center; margin:.8rem 0; }
|
||
.dot { width:8px; height:8px; border-radius:50%; background:#dadad0; }
|
||
.dot.active { background:var(--fjord); }
|
||
.tut-btn {
|
||
width:100%; padding:.75rem; background:var(--fjord); color:#fff;
|
||
border:none; border-radius:10px; font-weight:700; font-size:.95rem; cursor:pointer;
|
||
}
|
||
.tut-btn:hover { background:var(--fjord-d); }
|
||
|
||
/* === END SCREEN === */
|
||
.end-screen {
|
||
position:fixed; inset:0; background:rgba(15,30,40,.9);
|
||
backdrop-filter:blur(10px); display:flex; align-items:center;
|
||
justify-content:center; z-index:100; padding:2rem;
|
||
}
|
||
.end-card { background:#fff; border-radius:20px; padding:2rem 2.5rem; max-width:500px; text-align:center; box-shadow:0 30px 80px rgba(0,0,0,.4); }
|
||
.end-icon { font-size:4rem; margin-bottom:.5rem; }
|
||
.end-card h2 { font-size:1.8rem; font-weight:900; letter-spacing:-.02em; margin-bottom:.3rem; }
|
||
.end-card .end-summary { color:var(--text2); margin-bottom:1rem; }
|
||
.end-stats { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; margin:1rem 0; }
|
||
.end-stat { background:#f7f6f3; border-radius:10px; padding:.8rem; }
|
||
.end-stat-val { font-size:1.4rem; font-weight:800; color:var(--fjord); }
|
||
.end-stat-lbl { font-size:.7rem; color:var(--text3); text-transform:uppercase; }
|
||
.end-goals { display:flex; flex-direction:column; gap:.3rem; margin:.8rem 0 1rem; text-align:left; }
|
||
.end-goal { display:flex; align-items:center; gap:.55rem; font-size:.82rem; padding:.35rem .55rem; border-radius:7px; background:#f7f6f3; }
|
||
.end-goal.done { color:var(--moss); background:var(--moss-l); }
|
||
.end-goal.fail { color:var(--danger); background:var(--coral-l); }
|
||
.end-goal-icon { font-weight:900; font-size:.95rem; width:1rem; text-align:center; }
|
||
.end-summary strong { color:var(--danger); }
|
||
|
||
/* === CITIZEN MODAL === */
|
||
.citizen-overlay {
|
||
position:fixed; inset:0; z-index:80;
|
||
background:rgba(15,30,40,.78); backdrop-filter:blur(8px);
|
||
display:flex; align-items:center; justify-content:center; padding:1.5rem;
|
||
animation:fadein .2s ease-out;
|
||
}
|
||
@keyframes fadein { from { opacity:0; } to { opacity:1; } }
|
||
.citizen-card {
|
||
background:#fff; border-radius:18px; padding:1.6rem 1.8rem;
|
||
max-width:520px; width:100%; box-shadow:0 30px 80px rgba(0,0,0,.4);
|
||
animation:cardpop .25s cubic-bezier(.22,1,.36,1);
|
||
}
|
||
@keyframes cardpop {
|
||
from { transform:scale(.92) translateY(10px); opacity:0; }
|
||
to { transform:scale(1) translateY(0); opacity:1; }
|
||
}
|
||
.citizen-head { display:flex; gap:.9rem; align-items:center; margin-bottom:.9rem; }
|
||
.citizen-avatar {
|
||
width:56px; height:56px; border-radius:50%;
|
||
background:linear-gradient(135deg, var(--fjord-l), #fff);
|
||
border:2px solid var(--fjord);
|
||
display:flex; align-items:center; justify-content:center;
|
||
font-size:2rem; flex-shrink:0;
|
||
}
|
||
.citizen-title { font-size:1.05rem; font-weight:800; color:var(--text); }
|
||
.citizen-sub { font-size:.7rem; color:var(--text3); text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
|
||
.citizen-message {
|
||
background:#f7f6f3; border-left:3px solid var(--fjord);
|
||
padding:.85rem 1rem; border-radius:0 10px 10px 0;
|
||
font-size:.92rem; color:var(--text2); font-style:italic; line-height:1.5;
|
||
margin-bottom:1.1rem;
|
||
}
|
||
.citizen-choices { display:flex; flex-direction:column; gap:.5rem; }
|
||
.citizen-choice {
|
||
text-align:left; background:#f7f6f3; border:1.5px solid rgba(0,0,0,.06);
|
||
border-radius:11px; padding:.7rem .9rem; cursor:pointer; font-family:inherit;
|
||
transition:all .15s;
|
||
}
|
||
.citizen-choice:hover {
|
||
background:var(--fjord-l); border-color:var(--fjord);
|
||
transform:translateX(3px);
|
||
}
|
||
.citizen-choice-label { font-size:.88rem; font-weight:700; color:var(--text); }
|
||
.citizen-choice-desc { font-size:.72rem; color:var(--text3); margin-top:.15rem; }
|
||
|
||
/* === INFO OVERLAY (Begriffserklärungen für Kinder) === */
|
||
.info-overlay {
|
||
position:fixed; inset:0; z-index:90;
|
||
background:rgba(15,30,40,.72); backdrop-filter:blur(6px);
|
||
display:flex; align-items:center; justify-content:center; padding:1.5rem;
|
||
}
|
||
.info-card {
|
||
background:#fff; border-radius:18px; padding:1.6rem 1.9rem;
|
||
max-width:520px; box-shadow:0 30px 80px rgba(0,0,0,.4);
|
||
}
|
||
.info-card h3 { font-size:1.15rem; font-weight:800; margin-bottom:.7rem; color:var(--fjord); }
|
||
.info-card p { font-size:.92rem; color:var(--text2); line-height:1.55; margin-bottom:.8rem; }
|
||
.info-card .info-hint {
|
||
background:var(--fjord-l); border-left:3px solid var(--fjord);
|
||
padding:.7rem .9rem; border-radius:0 8px 8px 0; font-size:.82rem;
|
||
color:var(--fjord-d); margin-bottom:.9rem;
|
||
}
|
||
.info-card .tut-btn { margin-top:.3rem; }
|
||
|
||
/* === RESPONSIVE === */
|
||
@media (max-width: 1100px) {
|
||
:root { --side-w: 180px; --shop-w: 230px; }
|
||
}
|
||
@media (max-width: 900px) {
|
||
:root { --side-w: 160px; --shop-w: 200px; --graphs-h: 20vh; }
|
||
}
|
||
@media (max-width: 760px) {
|
||
.panel-status, .panel-goals, .panel-shop { display:none; }
|
||
.panel-graphs { left: var(--pad); right: var(--pad); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 3D-Welt füllt den ganzen Bildschirm -->
|
||
<div id="canvas-wrap"></div>
|
||
|
||
<!-- TOP BAR mit allen Steuerelementen -->
|
||
<nav class="floating floating-strong topbar">
|
||
<a href="index.html" class="brand">
|
||
<img src="assets/img/logo.png" alt="">
|
||
Klimawächter
|
||
<span class="badge-3d">3D</span>
|
||
</a>
|
||
|
||
<div class="topbar-controls">
|
||
<!-- Speed Controls -->
|
||
<div class="ctrl-group" title="Simulationsgeschwindigkeit">
|
||
<button class="icon-btn" data-speed="0" title="Pause">⏸</button>
|
||
<button class="icon-btn active" data-speed="1" title="Normal (×1)">×1</button>
|
||
<button class="icon-btn" data-speed="2" title="Schnell (×2)">×2</button>
|
||
<button class="icon-btn" data-speed="4" title="Sehr schnell (×4)">×4</button>
|
||
</div>
|
||
|
||
<!-- Kamera Controls -->
|
||
<div class="ctrl-group" title="Kamera">
|
||
<button class="icon-btn" id="cam-zoom-in" title="Hineinzoomen">+</button>
|
||
<button class="icon-btn" id="cam-zoom-out" title="Herauszoomen">−</button>
|
||
<button class="icon-btn" id="cam-center" title="Ansicht zentrieren">⊙</button>
|
||
</div>
|
||
|
||
<div class="topbar-sep"></div>
|
||
|
||
<!-- Schwierigkeitsgrad-Badge -->
|
||
<button class="level-badge" id="level-badge" title="Schwierigkeitsgrad ändern">
|
||
<span class="lb-emoji">🟡</span><span class="lb-text">L2</span>
|
||
</button>
|
||
|
||
<div class="topbar-sep"></div>
|
||
|
||
<!-- System Buttons — nur Symbole -->
|
||
<button class="icon-btn" id="btn-save" title="Stand speichern">💾</button>
|
||
<button class="icon-btn" id="btn-load" title="Stand laden">📂</button>
|
||
<button class="icon-btn" id="btn-finish" title="Simulation beenden">⏹</button>
|
||
<button class="icon-btn" id="btn-reset" title="Zurücksetzen">🔄</button>
|
||
<a class="icon-btn" href="index.html" title="Zurück zur Übersicht">🏠</a>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- LINKS: Status + Ziele — kompakt -->
|
||
<aside class="floating panel-status">
|
||
<div class="panel-h">📊 Status</div>
|
||
<div id="resources"></div>
|
||
<div class="panel-h panel-h-sub">🎯 Ziele</div>
|
||
<div id="status-goals"></div>
|
||
</aside>
|
||
|
||
<!-- LINKS DANEBEN: Ereignisse-Strip (sitzt direkt rechts vom Status-Panel,
|
||
ist standardmäßig nur eine Card hoch und zeigt das letzte Ereignis.
|
||
Klick → klappt nach oben auf, alle Ereignisse scrollbar.
|
||
Erneuter Klick (oder außerhalb) → schließt wieder. -->
|
||
<aside class="floating panel-events-strip" id="events-strip">
|
||
<div class="events-strip-header">
|
||
<span class="esh-title">📰 Ereignisse</span>
|
||
<span class="esh-toggle">▲</span>
|
||
</div>
|
||
<div id="events" class="events-strip-list"></div>
|
||
</aside>
|
||
|
||
<!-- RECHTS: Maßnahmen — über die volle Höhe, breiter -->
|
||
<aside class="floating panel-shop">
|
||
<div class="panel-h">🏗 Maßnahmen</div>
|
||
<div id="measures"></div>
|
||
</aside>
|
||
|
||
<!-- UNTEN MITTE: Graphen (doppelt so groß wie vorher) -->
|
||
<div class="floating panel-graphs">
|
||
<div class="graphs-row">
|
||
<div class="graph-card">
|
||
<div class="graph-title">
|
||
<span>🌫 CO₂ <span class="unit-chip" data-tip="ppm">ppm <span class="unit-i">i</span></span></span>
|
||
<button class="graph-info-btn" data-info="co2" title="Was ist CO₂?">i</button>
|
||
</div>
|
||
<svg class="graph-svg" id="graph-co2"></svg>
|
||
</div>
|
||
<div class="graph-card">
|
||
<div class="graph-title">
|
||
<span>🌡 Temperatur
|
||
<span class="unit-chip" data-tip="celsius">°C <span class="unit-i">i</span></span>
|
||
<span class="unit-chip" data-tip="paris_goal">Paris <span class="unit-i">i</span></span>
|
||
</span>
|
||
<button class="graph-info-btn" data-info="temperature" title="Was bedeutet das?">i</button>
|
||
</div>
|
||
<svg class="graph-svg" id="graph-temp"></svg>
|
||
</div>
|
||
<div class="graph-card">
|
||
<div class="graph-title">
|
||
<span>💰 Budget <span class="unit-chip" data-tip="mio_euro">Mio € <span class="unit-i">i</span></span></span>
|
||
<button class="graph-info-btn" data-info="budget" title="Was ist das Budget?">i</button>
|
||
</div>
|
||
<svg class="graph-svg" id="graph-budget"></svg>
|
||
</div>
|
||
<div class="graph-card">
|
||
<div class="graph-title">
|
||
<span>🌊 Meer <span class="unit-chip" data-tip="cm">cm <span class="unit-i">i</span></span></span>
|
||
<button class="graph-info-btn" data-info="sealevel" title="Meeresspiegelanstieg">i</button>
|
||
</div>
|
||
<svg class="graph-svg" id="graph-sea"></svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TUTORIAL OVERLAY -->
|
||
<div class="tutorial-overlay" id="tutorial">
|
||
<div class="tutorial-card">
|
||
<h3 id="tut-title">…</h3>
|
||
<p id="tut-text">…</p>
|
||
<div class="progress-dots" id="tut-dots"></div>
|
||
<button class="tut-btn" id="tut-next">Weiter →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="end-screen-host"></div>
|
||
<div id="citizen-event-host"></div>
|
||
<div id="info-overlay-host"></div>
|
||
<div class="graph-zoom-backdrop" id="graph-zoom-backdrop"></div>
|
||
|
||
<!-- Baumodus-Hinweis: erscheint mittig oben, sobald der Baueditor aktiv ist -->
|
||
<div class="build-banner" id="build-banner">
|
||
<span class="bb-emoji" id="bb-emoji">🏗</span>
|
||
<div class="bb-text">
|
||
<div><span id="bb-name" class="bb-name">Maßnahme</span> platzieren</div>
|
||
<div class="bb-hint">Klick = bauen · weitere Klicks = Mehrfach-Bau · <kbd>ESC</kbd> oder Rechtsklick = beenden · <kbd>← →</kbd> drehen</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Difficulty Selector — wird nur beim ERSTEN Start oder bei Klick aufs Level-Badge angezeigt -->
|
||
<div class="difficulty-overlay" id="difficulty-overlay" style="display:none">
|
||
<div class="difficulty-card">
|
||
<h2>Wähle deinen Schwierigkeitsgrad</h2>
|
||
<p class="subtitle">Du kannst das später jederzeit oben in der Leiste ändern.</p>
|
||
<div class="difficulty-buttons">
|
||
<button class="difficulty-btn lvl-1" data-level="1">
|
||
<div class="difficulty-emoji">🟢</div>
|
||
<div class="difficulty-name">Lernen</div>
|
||
<div class="difficulty-desc">Sanfter Einstieg. Viel Geld, milde Folgen, lange Schonzeit.</div>
|
||
<div class="difficulty-stats">950 Mio € Start · 174 €/J</div>
|
||
</button>
|
||
<button class="difficulty-btn lvl-2" data-level="2">
|
||
<div class="difficulty-emoji">🟡</div>
|
||
<div class="difficulty-name">Üben</div>
|
||
<div class="difficulty-desc">Strategie nötig, du musst wirklich nachdenken.</div>
|
||
<div class="difficulty-stats">480 Mio € Start · 93 €/J</div>
|
||
</button>
|
||
<button class="difficulty-btn lvl-3" data-level="3">
|
||
<div class="difficulty-emoji">🔴</div>
|
||
<div class="difficulty-name">Profi</div>
|
||
<div class="difficulty-desc">Hart. Kein Spielraum, schnelle Konsequenzen.</div>
|
||
<div class="difficulty-stats">350 Mio € Start · 78 €/J</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import { KlimawaechterGame, MEASURES, DIFFICULTY_LEVELS } from './src/sims/sim-05-treibhaus/game.ts'
|
||
import { KlimawaechterRenderer3D } from './src/sims/sim-05-treibhaus-3d/game-renderer-3d.ts'
|
||
import { GameUI } from './src/ui/game-ui.ts'
|
||
import { INFO_TOPICS, openInfoOverlay, installTooltips } from './src/ui/info-overlay.ts'
|
||
|
||
// Globale Tooltip-Listener für alle data-tip="…" Elemente
|
||
installTooltips()
|
||
|
||
// === Schwierigkeitsgrad bestimmen ===
|
||
// Reihenfolge: URL-Parameter > Save-Datei > localStorage > Selector zeigen
|
||
const LEVEL_KEY = 'klimawaechter-3d-difficulty'
|
||
const DIFFICULTY_OVERLAY = document.getElementById('difficulty-overlay')
|
||
|
||
function readStoredLevel() {
|
||
const url = parseInt(new URLSearchParams(location.search).get('level') || '0', 10)
|
||
if (url === 1 || url === 2 || url === 3) return url
|
||
// Save-Datei berücksichtigen — wenn ein Save existiert, dessen Level übernehmen
|
||
try {
|
||
const raw = localStorage.getItem('klimawaechter-3d-save')
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
const lvl = parsed?.subclass?.difficulty
|
||
if (lvl === 1 || lvl === 2 || lvl === 3) return lvl
|
||
}
|
||
} catch {}
|
||
const stored = parseInt(localStorage.getItem(LEVEL_KEY) || '0', 10)
|
||
if (stored === 1 || stored === 2 || stored === 3) return stored
|
||
return 0
|
||
}
|
||
|
||
function showSelector() {
|
||
DIFFICULTY_OVERLAY.style.display = 'flex'
|
||
}
|
||
function hideSelector() {
|
||
DIFFICULTY_OVERLAY.style.display = 'none'
|
||
}
|
||
|
||
// Wenn Spieler im Selector ein Level wählt → speichern und Seite neu laden
|
||
DIFFICULTY_OVERLAY.querySelectorAll('.difficulty-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const lvl = parseInt(btn.getAttribute('data-level'), 10)
|
||
if (lvl === 1 || lvl === 2 || lvl === 3) {
|
||
localStorage.setItem(LEVEL_KEY, String(lvl))
|
||
// Bei Level-Wechsel altes Save löschen, damit ein neues Spiel startet
|
||
localStorage.removeItem('klimawaechter-3d-save')
|
||
location.reload()
|
||
}
|
||
})
|
||
})
|
||
|
||
let currentLevel = readStoredLevel()
|
||
if (currentLevel === 0) {
|
||
// Erstes Spiel — Selector zeigen, Spiel-Init wartet auf Klick (löst reload aus)
|
||
showSelector()
|
||
// Defensive: Wenn der User die Seite einfach schließt, kein Spiel starten
|
||
throw new Error('Waiting for difficulty selection')
|
||
}
|
||
|
||
const game = new KlimawaechterGame(currentLevel)
|
||
|
||
// Maßnahmen pro Schwierigkeitsgrad filtern.
|
||
// Level 1: nur die Kern-Maßnahmen (siehe DIFFICULTY_LEVELS[1].availableMeasures)
|
||
// Level 2: Mid-Tier ohne die fragwürdigsten Optionen
|
||
// Level 3: alle
|
||
const allowedMeasures = DIFFICULTY_LEVELS[currentLevel]?.availableMeasures
|
||
const filteredMeasures = allowedMeasures
|
||
? MEASURES.filter(m => allowedMeasures.includes(m.id))
|
||
: MEASURES
|
||
|
||
const shopItems = filteredMeasures.map(m => {
|
||
const badges = []
|
||
if (m.co2Reduction > 0) badges.push({ label: `−${m.co2Reduction} CO₂/J`, type: 'reduction' })
|
||
if (m.co2Reduction < 0) badges.push({ label: `+${(-m.co2Reduction).toFixed(2)} CO₂/J`, type: 'quality' })
|
||
if (m.protection > 0) badges.push({ label: `🛡 +${m.protection} cm`, type: 'protection' })
|
||
if (m.powerOutput) badges.push({ label: `⚡ +${m.powerOutput} MW`, type: 'capacity' })
|
||
if (m.id === 'sand-fill') badges.push({ label: '⏳ erodiert', type: 'neutral' })
|
||
return {
|
||
id: m.id,
|
||
name: m.name,
|
||
emoji: m.emoji,
|
||
description: m.description,
|
||
cost: m.cost,
|
||
upkeep: m.upkeep,
|
||
badges,
|
||
}
|
||
})
|
||
|
||
const ui = new GameUI({
|
||
game,
|
||
Renderer: KlimawaechterRenderer3D,
|
||
shopItems,
|
||
onBuy: (id) => game.buyMeasure(id),
|
||
onDemolish: (id) => game.demolishMeasure(id),
|
||
getOwnedCount: (id) => game.getMeasureCount(id),
|
||
// Baueditor: Klick auf Maßnahme → Placement-Mode im Renderer starten,
|
||
// statt sofort zu kaufen. Wenn der Renderer ein Placement bestätigt,
|
||
// ruft er den onConfirm-Callback mit der gewählten Position auf.
|
||
onStartPlacement: (id) => {
|
||
const r = ui.getRenderer()
|
||
if (!r || typeof r.startPlacement !== 'function') {
|
||
// Fallback: Auto-Platzierung
|
||
game.buyMeasure(id)
|
||
return
|
||
}
|
||
// Vorab-Check: Budget reicht?
|
||
const measure = MEASURES.find(m => m.id === id)
|
||
if (!measure) return
|
||
if (game.getResource('budget') < measure.cost) {
|
||
// game.buyMeasure() würde das Warning-Event setzen — wir nutzen es als Trigger
|
||
game.buyMeasure(id)
|
||
return
|
||
}
|
||
// Placement-Mode → Simulation pausieren bis bestätigt/abgebrochen
|
||
pauseGameForOverlay('placement-mode')
|
||
// Baumodus-Banner einblenden
|
||
const banner = document.getElementById('build-banner')
|
||
const bbEmoji = document.getElementById('bb-emoji')
|
||
const bbName = document.getElementById('bb-name')
|
||
if (banner && bbEmoji && bbName) {
|
||
bbEmoji.textContent = measure.emoji
|
||
bbName.textContent = measure.name
|
||
banner.classList.add('visible')
|
||
}
|
||
r.startPlacement(
|
||
id,
|
||
(mid, x, z) => {
|
||
// Confirmed: kaufen mit Position. Mehrfach-Bau: Mode bleibt aktiv.
|
||
game.buyMeasure(mid, { x, z })
|
||
const m = MEASURES.find(x => x.id === mid)
|
||
if (!m || game.getResource('budget') < m.cost) {
|
||
r.cancelPlacement()
|
||
}
|
||
},
|
||
() => {
|
||
// Cancelled (ESC, Rechtsklick, oder Budget aufgebraucht)
|
||
if (banner) banner.classList.remove('visible')
|
||
resumeGameForOverlay('placement-mode')
|
||
},
|
||
)
|
||
},
|
||
graphs: [
|
||
{
|
||
id: 'graph-co2', title: 'CO₂', field: 'co2',
|
||
yMin: 380, yMax: 700, color: '#8a7868', unit: 'ppm',
|
||
zones: [
|
||
{ from: 380, to: 450, color: 'rgba(140,200,140,.18)', label: 'Klimaziel' },
|
||
{ from: 450, to: 550, color: 'rgba(220,190,120,.18)', label: 'Kritisch' },
|
||
{ from: 550, to: 700, color: 'rgba(200,100,100,.18)', label: 'Gefahr' },
|
||
],
|
||
lines: [
|
||
{ at: 450, color: 'rgba(100,60,40,.6)', label: 'Ziel', dashed: true },
|
||
],
|
||
},
|
||
{
|
||
id: 'graph-temp', title: 'Temperatur', field: 'temperature',
|
||
yMin: 15, yMax: 19, color: '#c07a6b', unit: '°C',
|
||
zones: [
|
||
{ from: 15, to: 16, color: 'rgba(140,200,140,.18)', label: 'sicher' },
|
||
{ from: 16, to: 17, color: 'rgba(220,190,120,.18)', label: '+1°C' },
|
||
{ from: 17, to: 18, color: 'rgba(220,140,100,.20)', label: '+2°C (Pariser Ziel)' },
|
||
{ from: 18, to: 19, color: 'rgba(200,80,60,.20)', label: 'Gefahr' },
|
||
],
|
||
lines: [
|
||
{ at: 17, color: '#b04a3a', label: 'Klimaziel 17°C', dashed: false },
|
||
],
|
||
},
|
||
{
|
||
id: 'graph-budget', title: 'Budget', field: 'budget',
|
||
yMin: -200, yMax: 1000, color: '#5a8a5e', unit: 'Mio €',
|
||
autoScale: true, // wandert mit, wenn mehr Geld verdient wird
|
||
zones: [
|
||
{ from: -200, to: 0, color: 'rgba(200,100,100,.18)', label: 'Pleite' },
|
||
],
|
||
lines: [
|
||
{ at: 0, color: '#b04a3a', label: '0', dashed: true },
|
||
],
|
||
},
|
||
{
|
||
id: 'graph-sea', title: 'Meeresspiegel', field: 'sealevel',
|
||
yMin: 0, yMax: 120, color: '#4a7c8a', unit: 'cm',
|
||
zones: [
|
||
{ from: 0, to: 30, color: 'rgba(140,200,140,.15)', label: 'Strand ok' },
|
||
{ from: 30, to: 60, color: 'rgba(220,190,120,.18)', label: 'Strand weg' },
|
||
{ from: 60, to: 100,color: 'rgba(220,140,100,.20)', label: 'Häuser!' },
|
||
{ from: 100,to: 120,color: 'rgba(200,80,60,.22)', label: 'Katastrophe' },
|
||
],
|
||
lines: [
|
||
{ at: 30, color: '#c4a35a', label: 'Strand', dashed: true },
|
||
{ at: 60, color: '#c07a6b', label: 'Häuser', dashed: true },
|
||
],
|
||
},
|
||
],
|
||
saveKey: 'klimawaechter-3d-save',
|
||
yearOffset: 2025,
|
||
// Status-Box zeigt nur noch Werte, die NICHT im Graph stehen
|
||
// (CO₂, Temperatur, Meer sind unten in den Graph-Pills sichtbar)
|
||
statusResources: ['budget', 'population', 'power'],
|
||
})
|
||
|
||
// === Kamera-Steuerung anbinden ===
|
||
const callRenderer = (method) => {
|
||
const r = ui.getRenderer()
|
||
if (r && typeof r[method] === 'function') r[method]()
|
||
}
|
||
document.getElementById('cam-zoom-in') ?.addEventListener('click', () => callRenderer('zoomIn'))
|
||
document.getElementById('cam-zoom-out')?.addEventListener('click', () => callRenderer('zoomOut'))
|
||
document.getElementById('cam-center') ?.addEventListener('click', () => callRenderer('recenter'))
|
||
|
||
// === Info-Buttons an Graphen anbinden ===
|
||
document.querySelectorAll('.graph-info-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const key = btn.getAttribute('data-info')
|
||
if (key && INFO_TOPICS[key]) openInfoOverlay(INFO_TOPICS[key])
|
||
})
|
||
})
|
||
|
||
// === Auto-Pause bei offenen Overlays ===
|
||
// Token-basiertes Pause-System: jeder Overlay-Typ registriert sich mit
|
||
// einem eindeutigen Schlüssel; solange mindestens ein Token aktiv ist,
|
||
// bleibt das Spiel bei Speed 0 stehen. Beim letzten Schließen wird die
|
||
// vorherige Geschwindigkeit wiederhergestellt.
|
||
const overlayPauseTokens = new Set()
|
||
let speedBeforeOverlayPause = 1
|
||
function pauseGameForOverlay(token) {
|
||
if (overlayPauseTokens.size === 0) {
|
||
// Aktuelle Geschwindigkeit merken (außer es ist eh schon pausiert)
|
||
const cur = game.getSnapshot().speed
|
||
if (cur !== 0) speedBeforeOverlayPause = cur
|
||
game.setSpeed(0)
|
||
// Speed-Buttons im UI synchronisieren
|
||
document.querySelectorAll('[data-speed]').forEach(b => b.classList.remove('active'))
|
||
document.querySelector('[data-speed="0"]')?.classList.add('active')
|
||
}
|
||
overlayPauseTokens.add(token)
|
||
}
|
||
function resumeGameForOverlay(token) {
|
||
overlayPauseTokens.delete(token)
|
||
if (overlayPauseTokens.size === 0) {
|
||
const restore = speedBeforeOverlayPause || 1
|
||
game.setSpeed(restore)
|
||
document.querySelectorAll('[data-speed]').forEach(b => b.classList.remove('active'))
|
||
document.querySelector(`[data-speed="${restore}"]`)?.classList.add('active')
|
||
}
|
||
}
|
||
|
||
// Citizen-Modal und Info-Overlay erscheinen/verschwinden ohne JS-API.
|
||
// Mit einem MutationObserver lauschen wir auf das DOM und pausieren das
|
||
// Spiel automatisch, sobald ein solches Modal sichtbar wird.
|
||
function observeOverlayForPause(selector, token) {
|
||
let isOpen = false
|
||
const check = () => {
|
||
const el = document.querySelector(selector)
|
||
const visible = !!el && getComputedStyle(el).display !== 'none'
|
||
if (visible && !isOpen) { isOpen = true; pauseGameForOverlay(token) }
|
||
else if (!visible && isOpen) { isOpen = false; resumeGameForOverlay(token) }
|
||
}
|
||
const mo = new MutationObserver(check)
|
||
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] })
|
||
return mo
|
||
}
|
||
observeOverlayForPause('.citizen-overlay', 'citizen-event')
|
||
observeOverlayForPause('.info-overlay', 'info-overlay')
|
||
|
||
// === Graph-Zoom: Click-to-zoom mit FLIP-Animation ===
|
||
// Klick auf eine Graph-Card → fliegt zentriert in die Mitte (groß).
|
||
// Erneuter Klick (oder Backdrop-Klick) → fliegt zurück.
|
||
//
|
||
// WICHTIG: .panel-graphs hat `backdrop-filter`, das macht es zum
|
||
// "containing block" für position:fixed Kinder. Wir hängen die Karte
|
||
// deshalb beim Zoom temporär ans <body> um (mit Platzhalter im Grid),
|
||
// damit position:fixed wirklich relativ zum Viewport funktioniert.
|
||
const zoomBackdrop = document.getElementById('graph-zoom-backdrop')
|
||
let zoomedCard = null
|
||
let zoomOriginalRect = null
|
||
let zoomOriginalParent = null
|
||
let zoomNextSibling = null
|
||
let zoomPlaceholder = null
|
||
|
||
function zoomGraph(card) {
|
||
if (zoomedCard) return
|
||
const rect = card.getBoundingClientRect()
|
||
zoomOriginalRect = rect
|
||
zoomedCard = card
|
||
zoomOriginalParent = card.parentNode
|
||
zoomNextSibling = card.nextSibling
|
||
|
||
// Platzhalter im Grid behalten, damit das Layout der anderen Karten stabil bleibt
|
||
zoomPlaceholder = document.createElement('div')
|
||
zoomPlaceholder.className = 'graph-card-placeholder'
|
||
zoomPlaceholder.style.minWidth = '0'
|
||
zoomPlaceholder.style.minHeight = '0'
|
||
zoomOriginalParent.insertBefore(zoomPlaceholder, card)
|
||
|
||
// Karte ans <body> umhängen — escape backdrop-filter containing block
|
||
document.body.appendChild(card)
|
||
|
||
// Schritt 1: Karte fixiert an aktueller Position (visuell unverändert)
|
||
card.style.position = 'fixed'
|
||
card.style.left = rect.left + 'px'
|
||
card.style.top = rect.top + 'px'
|
||
card.style.width = rect.width + 'px'
|
||
card.style.height = rect.height + 'px'
|
||
card.style.margin = '0'
|
||
card.style.zIndex = 160
|
||
// Reflow erzwingen
|
||
void card.offsetWidth
|
||
// Schritt 2: Transition aktivieren und Zielposition setzen
|
||
card.style.transition = 'all .42s cubic-bezier(.22,1,.36,1)'
|
||
const targetW = Math.min(940, window.innerWidth * 0.78)
|
||
const targetH = Math.min(560, window.innerHeight * 0.72)
|
||
card.style.left = ((window.innerWidth - targetW) / 2) + 'px'
|
||
card.style.top = ((window.innerHeight - targetH) / 2) + 'px'
|
||
card.style.width = targetW + 'px'
|
||
card.style.height = targetH + 'px'
|
||
card.classList.add('zoomed')
|
||
zoomBackdrop.classList.add('active')
|
||
|
||
// Nach Animation-Ende: Graph neu zeichnen für die größere SVG-Fläche
|
||
const onEnd = () => {
|
||
card.removeEventListener('transitionend', onEnd)
|
||
if (typeof ui.redrawGraphs === 'function') ui.redrawGraphs()
|
||
}
|
||
card.addEventListener('transitionend', onEnd)
|
||
|
||
// Spiel pausieren während Zoom
|
||
pauseGameForOverlay('graph-zoom')
|
||
}
|
||
|
||
function unzoomGraph() {
|
||
if (!zoomedCard || !zoomOriginalRect) return
|
||
const card = zoomedCard
|
||
const rect = zoomOriginalRect
|
||
// Animation zurück zur Originalposition
|
||
card.style.left = rect.left + 'px'
|
||
card.style.top = rect.top + 'px'
|
||
card.style.width = rect.width + 'px'
|
||
card.style.height = rect.height + 'px'
|
||
card.classList.remove('zoomed')
|
||
zoomBackdrop.classList.remove('active')
|
||
|
||
const cleanup = () => {
|
||
card.removeEventListener('transitionend', cleanup)
|
||
// Karte zurück in den Original-Container an die richtige Stelle setzen
|
||
if (zoomPlaceholder && zoomPlaceholder.parentNode) {
|
||
zoomPlaceholder.parentNode.insertBefore(card, zoomPlaceholder)
|
||
zoomPlaceholder.remove()
|
||
} else if (zoomOriginalParent) {
|
||
if (zoomNextSibling && zoomNextSibling.parentNode === zoomOriginalParent) {
|
||
zoomOriginalParent.insertBefore(card, zoomNextSibling)
|
||
} else {
|
||
zoomOriginalParent.appendChild(card)
|
||
}
|
||
}
|
||
// Inline-Styles aufräumen
|
||
card.style.position = ''
|
||
card.style.left = ''
|
||
card.style.top = ''
|
||
card.style.width = ''
|
||
card.style.height = ''
|
||
card.style.margin = ''
|
||
card.style.zIndex = ''
|
||
card.style.transition = ''
|
||
if (typeof ui.redrawGraphs === 'function') ui.redrawGraphs()
|
||
zoomPlaceholder = null
|
||
zoomOriginalParent = null
|
||
zoomNextSibling = null
|
||
}
|
||
card.addEventListener('transitionend', cleanup)
|
||
zoomedCard = null
|
||
zoomOriginalRect = null
|
||
|
||
// Spiel wieder fortsetzen
|
||
resumeGameForOverlay('graph-zoom')
|
||
}
|
||
|
||
// Klick-Handler an alle Graph-Karten anhängen
|
||
document.querySelectorAll('.graph-card').forEach(card => {
|
||
card.addEventListener('click', (ev) => {
|
||
// Klick auf den Info-Button (i) NICHT als Zoom werten
|
||
if (ev.target.closest('.graph-info-btn')) return
|
||
// Klick auf einen Unit-Chip (für Tooltips) NICHT als Zoom werten
|
||
if (ev.target.closest('.unit-chip')) return
|
||
if (zoomedCard === card) {
|
||
unzoomGraph()
|
||
} else if (!zoomedCard) {
|
||
zoomGraph(card)
|
||
}
|
||
})
|
||
})
|
||
zoomBackdrop.addEventListener('click', unzoomGraph)
|
||
|
||
// === Events-Strip: Click toggles open/closed ===
|
||
// Default: nur die neueste Card sichtbar (max-height ~84px).
|
||
// Klick auf den Strip: max-height wächst, alle Ereignisse scrollbar.
|
||
// Klick außerhalb: schließt wieder. Während offen: Simulation pausiert.
|
||
const eventsStrip = document.getElementById('events-strip')
|
||
function openEventsStrip() {
|
||
if (!eventsStrip || eventsStrip.classList.contains('open')) return
|
||
eventsStrip.classList.add('open')
|
||
pauseGameForOverlay('events-strip')
|
||
}
|
||
function closeEventsStrip() {
|
||
if (!eventsStrip || !eventsStrip.classList.contains('open')) return
|
||
eventsStrip.classList.remove('open')
|
||
resumeGameForOverlay('events-strip')
|
||
}
|
||
if (eventsStrip) {
|
||
// Wir benutzen die capture-phase, damit der Strip-Toggle IMMER zuerst läuft
|
||
// (vor dem document-outside-click handler) und stoppen die Propagation,
|
||
// damit der document handler den Strip-Klick nie als "outside" interpretiert.
|
||
eventsStrip.addEventListener('click', (ev) => {
|
||
// Klick auf einen Info-Button im offenen Zustand NICHT als Toggle werten
|
||
if (eventsStrip.classList.contains('open') && ev.target.closest('.event-info-btn')) return
|
||
ev.stopPropagation()
|
||
if (eventsStrip.classList.contains('open')) closeEventsStrip()
|
||
else openEventsStrip()
|
||
})
|
||
// Klick außerhalb des Strips schließt ihn (im offenen Zustand)
|
||
document.addEventListener('click', (ev) => {
|
||
if (!eventsStrip.classList.contains('open')) return
|
||
if (eventsStrip.contains(ev.target)) return
|
||
closeEventsStrip()
|
||
})
|
||
}
|
||
|
||
// === Level-Badge in der Topbar aktualisieren + klickbar machen ===
|
||
const lvlConfig = DIFFICULTY_LEVELS[currentLevel]
|
||
const badge = document.getElementById('level-badge')
|
||
if (badge && lvlConfig) {
|
||
badge.querySelector('.lb-emoji').textContent = lvlConfig.emoji
|
||
badge.querySelector('.lb-text').textContent = `L${lvlConfig.id} · ${lvlConfig.label}`
|
||
badge.title = `Schwierigkeit: ${lvlConfig.label} — klicken, um zu wechseln (startet neu)`
|
||
badge.addEventListener('click', () => {
|
||
if (confirm('Schwierigkeit wechseln? Deine aktuelle Simulation geht verloren.')) {
|
||
showSelector()
|
||
}
|
||
})
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|