Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp

- PHP/MySQL Backend (XAMPP + Produktionsserver)
- Front-Controller, API-Endpunkte, Session-Management
- Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss)
- Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem)
- Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst
- persistence.ts: Dualer Speicher (localStorage + Server-API)
- 6 Unit-Test-Dateien fuer bestehende Simulationen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:43:42 +02:00
commit f22c5ebbfe
83 changed files with 22709 additions and 0 deletions
+545
View File
@@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Lieferketten-Planer*in</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,.85);
--glass-bg2: rgba(255,255,255,.92);
--glass-bord: rgba(255,255,255,.55);
--glass-shadow: 0 8px 32px rgba(20,40,60,.18);
--topbar-h: 2.8rem;
--side-w: 220px;
--pad: .65rem;
}
html, body { height:100%; overflow:hidden; }
body { font-family:'Inter',system-ui,sans-serif; background:#dceaf0; color:var(--text); line-height:1.5; }
#canvas-wrap { position:fixed; inset:0; z-index:0; }
/* === 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;
}
/* === TOPBAR === */
.topbar {
top:0; left:0; right:0;
padding:.35rem .7rem;
display:flex; justify-content:space-between; align-items:center; gap:.8rem;
border-radius:0; 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-controls { display:flex; gap:.5rem; align-items:center; }
.icon-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 { 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; }
.topbar-sep { width:1px; height:24px; background:rgba(0,0,0,.08); }
/* === STATUS-PANEL (LINKS) === */
.panel-h {
font-size:.58rem; font-weight:800; color:var(--fjord);
text-transform:uppercase; letter-spacing:.07em; margin-bottom:.4rem;
}
.panel-h-sub { margin-top:.9rem; padding-top:.6rem; border-top:1px solid rgba(0,0,0,.08); }
.panel-status {
top: calc(var(--topbar-h) + var(--pad));
left: var(--pad);
width: var(--side-w);
padding: .6rem .75rem;
bottom: var(--pad);
overflow-y: auto;
}
.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; }
/* === 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; }
/* === BESTELL-FORMULAR (RECHTS) === */
.panel-order {
top: calc(var(--topbar-h) + var(--pad));
right: var(--pad);
bottom: var(--pad);
width: 300px;
padding: .85rem 1rem 1rem;
overflow-y: auto;
}
.order-section { margin-bottom: 1rem; }
.order-section h4 {
font-size: .65rem; font-weight: 800; color: var(--fjord);
text-transform: uppercase; letter-spacing: .07em;
margin-bottom: .35rem;
}
.order-options { display: flex; flex-direction: column; gap: .25rem; }
.order-option {
display: flex; align-items: center; gap: .4rem;
padding: .35rem .5rem; border-radius: 8px;
background: rgba(255,255,255,.55);
border: 1.5px solid rgba(0,0,0,.06);
cursor: pointer; transition: all .12s;
font-family: inherit; text-align: left;
}
.order-option:hover { border-color: var(--fjord); background: var(--fjord-l); }
.order-option.selected { border-color: var(--moss); background: var(--moss-l); }
.oo-flag { font-size: .9rem; flex-shrink: 0; }
.oo-info { flex: 1; min-width: 0; }
.oo-name { font-size: .68rem; font-weight: 700; line-height: 1.15; }
.oo-stats {
font-size: .55rem; color: var(--text3); line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.oo-stats .oo-stat { display: inline-block; margin-right: .35rem; }
.oo-stat-good { color: var(--moss); }
.oo-stat-bad { color: var(--danger); }
.transport-row { display: flex; gap: .25rem; }
.transport-btn {
flex: 1; padding: .4rem .25rem; border-radius: 8px;
background: rgba(255,255,255,.55); border: 1.5px solid rgba(0,0,0,.06);
cursor: pointer; font-family: inherit;
display: flex; flex-direction: column; align-items: center; gap: .1rem;
transition: all .12s;
}
.transport-btn:hover { border-color: var(--fjord); background: var(--fjord-l); }
.transport-btn.selected { border-color: var(--moss); background: var(--moss-l); }
.transport-btn .tb-emoji { font-size: 1.1rem; }
.transport-btn .tb-name { font-size: .55rem; font-weight: 700; }
.order-summary {
margin-top: .8rem; padding: .65rem .75rem;
background: rgba(0,0,0,.04); border-radius: 9px;
font-size: .68rem; line-height: 1.5;
}
.order-summary .os-row {
display: flex; justify-content: space-between;
font-variant-numeric: tabular-nums;
}
.order-summary .os-row strong { font-weight: 800; }
.os-good { color: var(--moss); }
.os-bad { color: var(--danger); }
.order-btn {
margin-top: .7rem; width: 100%;
padding: .65rem; border: none; border-radius: 9px;
background: var(--fjord); color: #fff;
font-weight: 800; font-size: .85rem; cursor: pointer;
font-family: inherit; transition: all .15s;
}
.order-btn:hover { background: var(--fjord-d); }
.order-btn:disabled { background: #c0c0c0; cursor: not-allowed; }
/* === EVENTS-STRIP (unten links neben dem Status) === */
.panel-events-strip {
position: fixed;
left: calc(var(--side-w) + var(--pad) * 2);
bottom: var(--pad);
width: 280px;
max-height: 90px;
padding: .35rem .55rem .45rem;
overflow: hidden;
cursor: pointer;
transition: max-height .38s cubic-bezier(.22,1,.36,1);
z-index: 12;
display: flex; flex-direction: column; gap: .25rem;
}
.panel-events-strip.open {
max-height: calc(100vh - var(--topbar-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 { 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;
}
.panel-events-strip:not(.open) .events-strip-list .event:not(:first-child) {
display: none;
}
.panel-events-strip:not(.open) .events-strip-list .event {
margin-bottom: 0;
}
.event {
padding:.3rem .45rem; border-radius:7px; margin-bottom:.25rem;
font-size:.65rem; 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); }
/* === 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; }
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<!-- TOP BAR -->
<nav class="floating topbar">
<a href="index.html" class="brand">
<img src="assets/img/logo.png" alt="">
Lieferketten-Planer*in
</a>
<div class="topbar-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>
<div class="topbar-sep"></div>
<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 -->
<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 -->
<aside class="floating panel-events-strip" id="events-strip">
<div class="events-strip-header">
<span>📰 Ereignisse</span>
<span class="esh-toggle"></span>
</div>
<div id="events" class="events-strip-list"></div>
</aside>
<!-- RECHTS: Bestell-Formular -->
<aside class="floating panel-order">
<div class="order-section">
<h4>🌾 Baumwolle</h4>
<div class="order-options" id="cotton-options"></div>
</div>
<div class="order-section">
<h4>🚚 Transport zur Näherei</h4>
<div class="transport-row" id="transport1-row"></div>
</div>
<div class="order-section">
<h4>🏭 Näherei</h4>
<div class="order-options" id="factory-options"></div>
</div>
<div class="order-section">
<h4>🚚 Transport nach Wien</h4>
<div class="transport-row" id="transport2-row"></div>
</div>
<div class="order-summary" id="order-summary"></div>
<button class="order-btn" id="order-btn" disabled>Bestellen</button>
</aside>
<!-- TUTORIAL -->
<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>
<script type="module">
import {
LieferkettenGame, COTTON_SOURCES, FACTORIES, TRANSPORT_MODES, DEFAULT_CONFIG,
} from './src/sims/sim-10-lieferketten/game.ts'
import { LieferkettenRenderer } from './src/sims/sim-10-lieferketten/renderer.ts'
import { GameUI } from './src/ui/game-ui.ts'
import { installTooltips } from './src/ui/info-overlay.ts'
installTooltips()
// === Lehrer-Konfiguration via URL-Parameter ===
// ?config=<json> erlaubt der Lehrperson, die drei Hauptparameter zu setzen.
function readConfig() {
try {
const raw = new URLSearchParams(location.search).get('config')
if (raw) return JSON.parse(decodeURIComponent(raw))
} catch {}
return {}
}
const userConfig = readConfig()
const game = new LieferkettenGame(userConfig)
// === GameUI ohne Shop (wir bauen unser eigenes Bestell-Formular) ===
// Wir geben einen leeren shopItems-Array, damit GameUI nichts rendert.
const ui = new GameUI({
game,
Renderer: LieferkettenRenderer,
shopItems: [],
onBuy: () => false,
graphs: [],
saveKey: 'lieferketten-save',
yearOffset: 2025,
})
// === Bestell-Formular bauen ===
const filterByConfig = (list, allowed) => list.filter(x => allowed.includes(x.id))
const cottonAvailable = filterByConfig(COTTON_SOURCES, game.getConfig().enabledOptions.cotton)
const factoryAvailable = filterByConfig(FACTORIES, game.getConfig().enabledOptions.factory)
const transportAvailable = TRANSPORT_MODES.filter(t => game.getConfig().enabledOptions.transport.includes(t.id))
const currentOrder = {
cottonId: null,
factoryId: null,
transport1: null,
transport2: null,
}
function ratingClass(value, goodAbove, badBelow) {
if (value >= goodAbove) return 'oo-stat-good'
if (value < badBelow) return 'oo-stat-bad'
return ''
}
function renderCottonOptions() {
const el = document.getElementById('cotton-options')
el.innerHTML = cottonAvailable.map(c => `
<button class="order-option ${currentOrder.cottonId === c.id ? 'selected' : ''}" data-id="${c.id}">
<span class="oo-flag">${c.flag}</span>
<div class="oo-info">
<div class="oo-name">${c.country}</div>
<div class="oo-stats">
<span class="oo-stat">€${c.pricePerShirt.toFixed(2)}</span>
<span class="oo-stat ${ratingClass(-c.co2PerShirt, -1.5, -2.4)}">${c.co2PerShirt.toFixed(1)} kg CO₂</span>
<span class="oo-stat ${ratingClass(c.ethicsScore, 7, 5)}">⚖️ ${c.ethicsScore}</span>
</div>
</div>
</button>
`).join('')
el.querySelectorAll('.order-option').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder.cottonId = btn.dataset.id
renderCottonOptions(); updatePreview()
})
})
}
function renderFactoryOptions() {
const el = document.getElementById('factory-options')
el.innerHTML = factoryAvailable.map(f => `
<button class="order-option ${currentOrder.factoryId === f.id ? 'selected' : ''}" data-id="${f.id}">
<span class="oo-flag">${f.flag}</span>
<div class="oo-info">
<div class="oo-name">${f.country}</div>
<div class="oo-stats">
<span class="oo-stat">€${f.pricePerShirt.toFixed(2)}</span>
<span class="oo-stat ${ratingClass(-f.co2PerShirt, -1.0, -1.5)}">${f.co2PerShirt.toFixed(1)} kg CO₂</span>
<span class="oo-stat ${ratingClass(f.ethicsScore, 7, 5)}">⚖️ ${f.ethicsScore}</span>
</div>
</div>
</button>
`).join('')
el.querySelectorAll('.order-option').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder.factoryId = btn.dataset.id
renderFactoryOptions(); updatePreview()
})
})
}
function renderTransportRow(rowId, key) {
const el = document.getElementById(rowId)
el.innerHTML = transportAvailable.map(t => `
<button class="transport-btn ${currentOrder[key] === t.id ? 'selected' : ''}" data-id="${t.id}">
<span class="tb-emoji">${t.emoji}</span>
<span class="tb-name">${t.name.split(' ')[0]}</span>
</button>
`).join('')
el.querySelectorAll('.transport-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentOrder[key] = btn.dataset.id
renderTransportRow(rowId, key); updatePreview()
})
})
}
function updatePreview() {
// Renderer-Vorschau aktualisieren
const r = ui.getRenderer()
if (r && typeof r.setPreviewOrder === 'function') {
r.setPreviewOrder(currentOrder)
}
// Order-Summary berechnen
const summary = document.getElementById('order-summary')
const btn = document.getElementById('order-btn')
const complete = currentOrder.cottonId && currentOrder.factoryId && currentOrder.transport1 && currentOrder.transport2
if (!complete) {
summary.innerHTML = '<div style="color:var(--text3);text-align:center;font-size:.7rem">Wähle alle 4 Stationen aus.</div>'
btn.disabled = true
return
}
// Vorschau-Berechnung lokal (mirror der placeOrder-Logik)
const c = COTTON_SOURCES.find(x => x.id === currentOrder.cottonId)
const f = FACTORIES.find(x => x.id === currentOrder.factoryId)
const t1 = TRANSPORT_MODES.find(x => x.id === currentOrder.transport1)
const t2 = TRANSPORT_MODES.find(x => x.id === currentOrder.transport2)
// Sehr grobe Schätzung — wir nutzen die Distanz-Mittelwerte (nicht die Effekte)
const dist1 = 5 // ~5000 km Schätzwert für Vorschau
const dist2 = 3 // ~3000 km Schätzwert
const t1Cost = t1.costPer1000km * dist1
const t2Cost = t2.costPer1000km * dist2
const t1CO2 = t1.co2Per1000km * dist1
const t2CO2 = t2.co2Per1000km * dist2
const costPerShirt = c.pricePerShirt + f.pricePerShirt + t1Cost + t2Cost
const co2PerShirt = c.co2PerShirt + f.co2PerShirt + t1CO2 + t2CO2
const ethics = (c.ethicsScore + f.ethicsScore) / 2
const profitPerShirt = 12 - costPerShirt
const profitMio = (5000 * profitPerShirt) / 1_000_000
const profitClass = profitMio >= 0 ? 'os-good' : 'os-bad'
const co2Class = co2PerShirt < 6 ? 'os-good' : 'os-bad'
const ethicsClass = ethics >= 5 ? 'os-good' : 'os-bad'
summary.innerHTML = `
<div class="os-row"><span>Kosten/Stück</span><strong>€${costPerShirt.toFixed(2)}</strong></div>
<div class="os-row"><span>Gewinn/Charge</span><strong class="${profitClass}">${profitMio >= 0 ? '+' : ''}${profitMio.toFixed(2)} Mio €</strong></div>
<div class="os-row"><span>CO₂/Stück</span><strong class="${co2Class}">${co2PerShirt.toFixed(1)} kg</strong></div>
<div class="os-row"><span>Ø Ethik</span><strong class="${ethicsClass}">${ethics.toFixed(1)} / 10</strong></div>
`
btn.disabled = false
}
document.getElementById('order-btn').addEventListener('click', () => {
if (!currentOrder.cottonId || !currentOrder.factoryId || !currentOrder.transport1 || !currentOrder.transport2) return
game.placeOrder({ ...currentOrder })
// Sprite-Animation auf der Karte
const r = ui.getRenderer()
if (r && typeof r.animateOrder === 'function') r.animateOrder(currentOrder)
})
// Events-Strip toggle
const eventsStrip = document.getElementById('events-strip')
eventsStrip.addEventListener('click', (ev) => {
ev.stopPropagation()
eventsStrip.classList.toggle('open')
})
document.addEventListener('click', (ev) => {
if (!eventsStrip.classList.contains('open')) return
if (eventsStrip.contains(ev.target)) return
eventsStrip.classList.remove('open')
})
// Initial render
renderCottonOptions()
renderFactoryOptions()
renderTransportRow('transport1-row', 'transport1')
renderTransportRow('transport2-row', 'transport2')
updatePreview()
</script>
</body>
</html>