Files
geograsim/App/sims/staustufen/engine.js
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

1233 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Staustufen — Wasserkraft in der Kaskade
* Level 1 — Engine
*
* Ein Durchgang: 5 simulierte Tage à 12 Ticks (= 60 Ticks gesamt).
* Bei normalem Tempo läuft ein Tick alle 1.5 s → komplette Bilanz in 90 s.
* Im 4×-Tempo entsprechend kürzer.
*
* Mechanik (vereinfacht für L1):
* - 2 Speicher (oben/unten), Kapazität je 1000 Einheiten "Wasser-Energie"
* - 1 Pumpe (Unten → Oben), 4 Stufen
* - 1 Turbine (Oben → Unten), 4 Stufen
* - Pump-Wirkungsgrad 75 %, Turbinen-Wirkungsgrad 85 %
* - Konstanter Zufluss 2 Einheiten/Tick in den oberen Speicher
* - Bedarfskurve fix: Tag 1 niedrig, Tag 2 mittel, Tag 3 Spitze,
* Tag 4 niedrig, Tag 5 Spitze
*
* Bewertungs-Modell (Atlas-Update 2026-04-26): Netzbalance-Korridor.
* Pro Tick wird die Abweichung |consume - supply| / demand gemessen:
* ≤ 15 % = grüner Korridor (Netz im Gleichgewicht)
* ≤ 30 % = gelb (Lücke oder Überschuss spürbar)
* > 30 % = rot (kritische Abweichung)
* End-Bewertung: Anteil grüner Ticks + Verlustquote.
*/
(function () {
'use strict';
// ---------- Konstanten ----------
var TICKS_PER_DAY = 12;
var TOTAL_DAYS = 5;
var TOTAL_TICKS = TICKS_PER_DAY * TOTAL_DAYS;
var TANK_CAP = 1000;
// Maximale Wassermengen pro Tick (Volllast). Steuerwert ∈ [-1..+1]
// skaliert linear: pumpWater = -control × MAX_PUMP_RATE (für control < 0),
// turbWater = control × MAX_TURB_RATE (für control > 0). Das ist die
// stufenlose Variante — der Slider gibt jeden Wert zwischen voller Pumpe
// und voller Turbine frei.
var MAX_PUMP_RATE = 16;
var MAX_TURB_RATE = 16;
var PUMP_EFF = 0.75; // benötigte Strom-Einheit / Wasser-Einheit = 1/0.75 ≈ 1.33
var TURB_EFF = 0.85; // erzeugte Strom-Einheit / Wasser-Einheit = 0.85
var INFLOW_PER_TICK = 2; // konstanter natürlicher Zufluss in Tank Oben
var INIT_TOP = 300;
var INIT_BOT = 700;
// Bedarf pro Tick je Tag (Tagesmittel, Strom-Einheiten/Tick)
// Tag 1 niedrig, Tag 2 mittel, Tag 3 Spitze, Tag 4 niedrig, Tag 5 Spitze
var DEMAND_PER_DAY = [6, 12, 22, 6, 22];
// Tageswellenform — Strombedarf schwankt im Tagesverlauf
// (Stunde, Faktor) — Faktoren sind so kalibriert, dass das Tagesmittel
// exakt 1.0 ergibt.
var DEMAND_ANCHORS = [
{ h: 0, v: 0.72 }, // Nacht-Tal
{ h: 8, v: 1.16 }, // Morgenspitze
{ h: 13, v: 0.94 }, // Mittagsdelle
{ h: 19, v: 1.21 }, // Abendspitze
{ h: 24, v: 0.72 }, // wraparound
];
// Mittelwert per Trapez über 24h, gewichtet nach Segmentlänge:
// ((0.72+1.16)/2 * 8 + (1.16+0.94)/2 * 5
// + (0.94+1.21)/2 * 6 + (1.21+0.72)/2 * 5) / 24 ≈ 1.000
var DEMAND_FACTOR_MEAN = 1.000;
// Freie Einspeisung über den Tag — Solar (Mittag-Spitze) + Wind/Lauf
// (niedrige Grundlast nachts). Jeder Tag identisch.
// Tagesmittel ≈ 10.6 — bei Tag 1/4 (Bedarf-Mittel 6) entsteht Überschuss
// tagsüber, bei Tag 3/5 (Bedarf-Mittel 22) eine deutliche Lücke.
var SUPPLY_ANCHORS = [
{ h: 0, v: 6 }, // Nacht: nur Wind/Lauf
{ h: 6, v: 6 }, // Sonnenaufgang
{ h: 9, v: 12 }, // Solar steigt
{ h: 12, v: 22 }, // Mittags-Spitze (Solar)
{ h: 15, v: 18 }, // Nachmittag
{ h: 18, v: 8 }, // Sonnenuntergang
{ h: 21, v: 6 }, // Wind/Lauf
{ h: 24, v: 6 }, // wraparound
];
function supplyFactorAt(hour) {
if (hour < 0) hour = 0;
if (hour > 24) hour = 24;
for (var i = 0; i < SUPPLY_ANCHORS.length - 1; i++) {
var a = SUPPLY_ANCHORS[i], b = SUPPLY_ANCHORS[i + 1];
if (hour >= a.h && hour <= b.h) {
var t = (hour - a.h) / (b.h - a.h);
var s = t * t * (3 - 2 * t);
return a.v + (b.v - a.v) * s;
}
}
return SUPPLY_ANCHORS[0].v;
}
function supplyAtTick(day, tickInDay) {
var hour = tickInDay * 2;
return supplyFactorAt(hour);
}
// Smoothstep-Interpolation zwischen den Stützpunkten — weiche Kurve.
function demandFactorAt(hour) {
if (hour < 0) hour = 0;
if (hour > 24) hour = 24;
for (var i = 0; i < DEMAND_ANCHORS.length - 1; i++) {
var a = DEMAND_ANCHORS[i], b = DEMAND_ANCHORS[i + 1];
if (hour >= a.h && hour <= b.h) {
var t = (hour - a.h) / (b.h - a.h);
var s = t * t * (3 - 2 * t);
return (a.v + (b.v - a.v) * s) / DEMAND_FACTOR_MEAN;
}
}
return DEMAND_ANCHORS[0].v / DEMAND_FACTOR_MEAN;
}
// Bedarf an einem konkreten Tick: Tagesmittel × Wellenfaktor
function demandAtTick(day, tickInDay) {
var hour = tickInDay * 2; // 12 Ticks à 2 Std
if (day < 1) day = 1;
if (day > TOTAL_DAYS) day = TOTAL_DAYS;
return DEMAND_PER_DAY[day - 1] * demandFactorAt(hour);
}
// Tempo (Tick-Intervalle in ms)
// 1× = 5 s/Tick → 5 min Gesamt-Durchgang (L1 ruhig zum Lesen + Reagieren)
// 4× = 1.25 s/Tick → 75 s Vorlauf
var SPEED_MS = { '1x': 5000, '4x': 1250 };
// Netzbalance-Korridor (Anteil der Bedarfshöhe)
var CORRIDOR_GREEN = 0.15; // ≤ 15 % Abweichung = im Gleichgewicht
var CORRIDOR_YELLOW = 0.30; // ≤ 30 % = spürbar, aber nicht kritisch
// ---------- State ----------
var state = {
day: 1, // 1..5
tickInDay: 0, // 0..11 (0 = "00:00", 11 = "22:00")
totalTick: 0, // 0..60
paused: false,
finished: false,
speed: '1x',
rafId: null, // requestAnimationFrame-ID
simProgress: 0, // float, in Tick-Einheiten — pixelweiser Cursor
lastFrameMs: 0, // letzter rAF-Zeitstempel
tankTop: INIT_TOP,
tankBot: INIT_BOT,
// Stufenlose Steuerung — zwischen voller Pumpe (-1) und voller Turbine (+1).
// Mittelpunkt 0 = beides aus. Wird vom bidirektionalen Slider gesetzt.
control: 0,
// Erst-Aktivierungs-Flags, damit InfoCards „pumpe" / „turbine"
// einmalig feuern, sobald ein Wert in die jeweilige Hälfte wandert.
firstPumpUsed: false,
firstTurbineUsed: false,
// Pro Tick aktualisierte Anzeige-Werte
lastTurbineWater: 0,
lastPumpWater: 0,
lastTurbineElec: 0,
lastPumpElec: 0,
// Aktuelle Netzbalance-Klasse: 'green' | 'yellow-deficit' | 'yellow-surplus'
// | 'red-deficit' | 'red-surplus'
lastBalance: 'green',
lastGapRel: 0, // (consume - supply) / demand (signed)
// Pro-Tick-Historie für den Bedarfsgraph (Versorgungslinie)
history: { supply: [] },
totals: {
demand: 0, // gesamt Strom-Einheiten Bedarf (über 5 Tage)
freeSupplyTotal: 0, // gesamt freie Einspeisung
turbineSupplied: 0, // gesamt vom Turbinenstrom geliefert
pumpDrawn: 0, // gesamt vom Pumpen-Strom-Verbrauch
unmet: 0, // gesamt nicht-gedeckte Bedarfslücke
surplus: 0, // gesamt überschüssige Einspeisung
losses: 0, // gesamt Verluste durch Pumpen + Turbinieren
waterMoved: 0, // gesamt bewegtes Wasser
// Netzbalance-Korridor — Tick-Counter
ticksGreen: 0,
ticksYellow: 0,
ticksRed: 0,
},
hintsShown: { welcome: false, imbalance: false },
infocardsShown: {
anlage: false, pumpe: false, turbine: false,
korridor: false, spitze: false,
},
audioEnabled: false, // Default-Mute (Klassenraum-tauglich)
assessment: {
startMs: null,
pingTimer: null,
sentStarted: false,
sentCompleted: false,
},
};
// ---------- Helper ----------
function $(id) { return document.getElementById(id); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function fmt(n, dec) {
if (typeof dec !== 'number') dec = 0;
if (!isFinite(n)) return '0';
return n.toFixed(dec).replace(/\B(?=(\d{3})+(?!\d))/g, '');
}
function pct(v, base) {
if (!base) return 0;
return clamp(Math.round((v / base) * 100), 0, 100);
}
// ---------- Tick-Logik ----------
function tick() {
if (state.paused || state.finished) return;
if (state.totalTick >= TOTAL_TICKS) { finish(); return; }
var demand = demandAtTick(state.day, state.tickInDay);
// 1) Zufluss in oberen Speicher
state.tankTop = clamp(state.tankTop + INFLOW_PER_TICK, 0, TANK_CAP);
// 2) Pumpe Unten → Oben (control < 0) — stufenlos skaliert
var pumpWater = state.control < 0
? Math.abs(state.control) * MAX_PUMP_RATE : 0;
pumpWater = Math.min(pumpWater, state.tankBot, TANK_CAP - state.tankTop);
if (pumpWater < 0) pumpWater = 0;
state.tankBot -= pumpWater;
state.tankTop += pumpWater;
var pumpElec = pumpWater > 0 ? pumpWater / PUMP_EFF : 0;
var pumpLoss = pumpElec - pumpWater;
// 3) Turbine Oben → Unten (control > 0) — stufenlos skaliert
var turbWater = state.control > 0
? state.control * MAX_TURB_RATE : 0;
turbWater = Math.min(turbWater, state.tankTop, TANK_CAP - state.tankBot);
if (turbWater < 0) turbWater = 0;
state.tankTop -= turbWater;
state.tankBot += turbWater;
var turbElec = turbWater * TURB_EFF;
var turbLoss = turbWater - turbElec;
// 4) Bilanz pro Tick — Netzbalance-Korridor
// Verfügbar = freie Einspeisung (Solar/Wind, schwankt) + Turbinen-Strom
// Verbraucht = Bedarf + Pumpen-Strom-Verbrauch
// Korridor wird relativ zum Bedarf gemessen: |gap| / demand
var freeSupply = supplyAtTick(state.day, state.tickInDay);
var supply = freeSupply + turbElec;
var consume = demand + pumpElec;
var gap = consume - supply; // > 0 Lücke, < 0 Überschuss
var unmetT = gap > 0 ? gap : 0;
var surplusT = gap < 0 ? -gap : 0;
var gapRel = demand > 0 ? gap / demand : 0;
var absRel = Math.abs(gapRel);
var balance;
if (absRel <= CORRIDOR_GREEN) {
balance = 'green';
state.totals.ticksGreen += 1;
} else if (absRel <= CORRIDOR_YELLOW) {
balance = gap > 0 ? 'yellow-deficit' : 'yellow-surplus';
state.totals.ticksYellow += 1;
} else {
balance = gap > 0 ? 'red-deficit' : 'red-surplus';
state.totals.ticksRed += 1;
}
state.lastBalance = balance;
state.lastGapRel = gapRel;
// Erstes Mal außerhalb Korridor → InfoCard erklärt das Konzept.
// Erst ab Tick 2, damit nicht direkt nach dem Welcome-Card gefeuert wird.
if (balance !== 'green' && state.totalTick >= 1
&& !state.infocardsShown.korridor) {
showInfoCard('korridor');
}
state.totals.demand += demand;
state.totals.freeSupplyTotal += freeSupply;
state.totals.turbineSupplied += turbElec;
state.totals.pumpDrawn += pumpElec;
state.totals.unmet += unmetT;
state.totals.surplus += surplusT;
state.totals.losses += pumpLoss + turbLoss;
state.totals.waterMoved += pumpWater + turbWater;
// Versorgungs-Historie für den Bedarfsgraph: Netto-Lieferung ans Netz
// (freie Einspeisung + Turbine-Erzeugung Pump-Verbrauch)
state.history.supply.push(supply - pumpElec);
state.lastTurbineWater = turbWater;
state.lastPumpWater = pumpWater;
state.lastTurbineElec = turbElec;
state.lastPumpElec = pumpElec;
// 5) Tickzähler weiter
state.totalTick += 1;
state.tickInDay += 1;
if (state.tickInDay >= TICKS_PER_DAY) {
state.tickInDay = 0;
state.day += 1;
}
// Spitzen-Tag-InfoCard zu Beginn von Tag 3
if (state.day === 3 && state.tickInDay === 0
&& !state.infocardsShown.spitze) {
showInfoCard('spitze');
}
render();
// Ende erreicht?
if (state.totalTick >= TOTAL_TICKS) {
finish();
return;
}
// Hint #2 — Tag 1, Mitte, falls Bearbeiter*in noch gar nichts angefasst hat
if (state.day === 1 && state.tickInDay === 5 && !state.hintsShown.imbalance
&& state.control === 0) {
showHint('imbalance',
'Tipp: Solar liefert mehr als der Bedarf braucht.',
'Heute (Tag 1) liegt die <b>gelbe Solar-Linie</b> mittags weit ' +
'über der <b>roten Bedarfs-Linie</b> — es gibt Stromüberschuss. ' +
'Wenn du nichts machst, verpufft der einfach. Schieb den Regler ' +
'beim Maschinenhaus <b>nach links</b> (Pumpe), dann fängst du ' +
'den Überschuss ein und hebst Wasser nach oben. Das brauchst du ' +
'für die Abendspitzen in Tag 3 und 5.');
}
}
// ---------- Render ----------
function render() {
// state.day kann nach dem letzten Tick auf 6 stehen (Übergangsmoment vor finish).
var dayIdx = clamp(state.day - 1, 0, TOTAL_DAYS - 1);
var displayDay = dayIdx + 1;
var displayTickInDay = clamp(state.tickInDay, 0, TICKS_PER_DAY - 1);
var demand = demandAtTick(displayDay, displayTickInDay);
// Tag-Pille
var dayCapped = Math.min(state.day, TOTAL_DAYS);
$('dayPill').textContent = 'Tag ' + dayCapped + ' / ' + TOTAL_DAYS;
// Uhrzeit (00:00..22:00 in 2-h-Schritten)
var hour = state.tickInDay * 2;
$('kpiUhrzeit').textContent =
(hour < 10 ? '0' : '') + hour + ':00';
$('kpiTickInfo').textContent =
'Tick ' + (state.totalTick % TICKS_PER_DAY) + ' / ' + TICKS_PER_DAY;
// Netzbalance-KPI: Anteil Ticks im grünen Korridor (bisher)
var ticksDone = state.totals.ticksGreen
+ state.totals.ticksYellow
+ state.totals.ticksRed;
var greenPct = ticksDone > 0
? Math.round((state.totals.ticksGreen / ticksDone) * 100) : 100;
$('kpiNetzbalance').textContent = greenPct + ' %';
var bar = $('kpiNetzbalanceBar');
bar.style.width = greenPct + '%';
bar.classList.remove('warn', 'danger');
if (greenPct < 65) bar.classList.add('danger');
else if (greenPct < 80) bar.classList.add('warn');
// Sub-Zeile mit Lücke + Überschuss für Kontext
var unmetSoFar = state.totals.unmet;
var surplusSoFar = state.totals.surplus;
var subParts = [];
if (unmetSoFar >= 0.5) subParts.push('Lücke ' + fmt(unmetSoFar, 0));
if (surplusSoFar >= 0.5) subParts.push('Überschuss ' + fmt(surplusSoFar, 0));
$('kpiNetzbalanceSub').textContent = subParts.length === 0
? 'Bisher im Gleichgewicht.'
: subParts.join(' · ') + ' GWh';
// Verluste
$('kpiVerluste').textContent = fmt(state.totals.losses, 0) + ' GWh';
// Aktueller Korridor-Status — Live-Indikator
setBalanceIndicator(state.lastBalance, state.lastGapRel);
// Wasserstände auf dem SVG-Querschnitt
var topPct = Math.round((state.tankTop / TANK_CAP) * 100);
var botPct = Math.round((state.tankBot / TANK_CAP) * 100);
renderUpperLake(topPct);
renderLowerLake(botPct);
$('stTopPctLabel').textContent = topPct + ' %';
$('stBotPctLabel').textContent = botPct + ' %';
// Pumpe/Turbine-Symbole + Druckrohr-Animation
renderPipeFlow();
refreshControlUi();
var pumpSym = document.getElementById('stPumpSymbol');
var turbSym = document.getElementById('stTurbSymbol');
if (pumpSym) pumpSym.classList.toggle('active', state.control < -0.02);
if (turbSym) turbSym.classList.toggle('active', state.control > 0.02);
// Graph-Meta (Cursor-Bedarfswert + aktuelle freie Einspeisung)
var freeSupplyNow = supplyAtTick(displayDay, displayTickInDay);
$('graphDemandNow').textContent = fmt(demand, 0);
$('graphSupplyNow').textContent = fmt(freeSupplyNow, 0);
// Graph wird jetzt von der RAF-Schleife pixelweise gerendert —
// hier kein drawGraph()-Aufruf mehr nötig.
}
// Oberbecken — Wasserkörper innerhalb des Mulden-Polygons.
// ClipPath stUpperLakeClip: 660,140 → 910,140 → 850,200 → 680,200
// y=140 (Wasseroberkante bei voller Füllung)
// y=200 (Beckenboden — leer wenn Wasser bis dahin)
function renderUpperLake(pct) {
var el = document.getElementById('stWaterTop');
if (!el) return;
var bottomY = 200, topYAtFull = 140;
var depth = (bottomY - topYAtFull) * (pct / 100);
var y = bottomY - depth;
el.setAttribute('y', y);
el.setAttribute('height', depth);
el.classList.toggle('danger', pct < 15);
}
// Unterbecken — Wasserkörper im Tal.
// ClipPath stLowerLakeClip: 130,420 → 383,420 → 300,480 → 150,480
function renderLowerLake(pct) {
var el = document.getElementById('stWaterBot');
if (!el) return;
var bottomY = 480, topYAtFull = 420;
var depth = (bottomY - topYAtFull) * (pct / 100);
var y = bottomY - depth;
el.setAttribute('y', y);
el.setAttribute('height', depth);
el.classList.toggle('danger', pct < 15);
}
// Druckrohr-Wasseranimation: zeigt Fließrichtung + Geschwindigkeit.
// Stufenlos — Geschwindigkeit ∝ |state.control|. Es können mehrere Pfade
// (Pump-Pfad + Turbinen-Pfad) gleichzeitig animiert werden.
function renderPipeFlow() {
var nodes = document.querySelectorAll('.st-pipe-flow');
if (!nodes.length) return;
var c = state.control;
var mag = Math.abs(c);
// Geschwindigkeit invers zur Magnitude — bei Volllast 0.6 s, sehr schwach
// ~3 s. Min-Cap damit's nicht stehen bleibt bei wirklich kleinem Hub.
var dur = mag > 0.02
? (0.6 + (1 - Math.min(mag, 1)) * 2.4).toFixed(2) + 's'
: '1.5s';
nodes.forEach(function (el) {
el.classList.remove('active', 'pump', 'turbine');
el.style.setProperty('--st-flow-dur', dur);
if (c > 0.02) el.classList.add('active', 'turbine');
if (c < -0.02) el.classList.add('active', 'pump');
});
}
function setBalanceIndicator(balance, gapRel) {
var el = $('kpiKorridor');
var needle = $('livemeterNeedle');
// Magnitude immer positiv — Vorzeichen kommt aus dem Wort-Label
var pctStr = Math.abs(Math.round(gapRel * 100)) + ' %';
// Skala: Erzeugung Verbrauch (also gapRel).
// positiv = Überschuss → Nadel rechts (passt zum „+50 % Überschuss"-Label)
// negativ = Lücke → Nadel links (passt zum „−50 % Lücke"-Label)
if (needle) {
var meterValue = -gapRel;
var clamped = Math.max(-0.5, Math.min(0.5, meterValue));
var pos = (clamped + 0.5) * 100; // 0..100 %
needle.style.left = pos.toFixed(1) + '%';
}
if (!el) return;
var label, sub, cls;
switch (balance) {
case 'yellow-deficit':
label = '🟡 Lücke'; sub = 'unter Bedarf · ' + pctStr;
cls = 'st-balance-yellow';
break;
case 'red-deficit':
label = '🔴 Lücke'; sub = 'kritisch unter Bedarf · ' + pctStr;
cls = 'st-balance-red';
break;
case 'yellow-surplus':
label = '🟡 Überschuss'; sub = 'über Bedarf · ' + pctStr;
cls = 'st-balance-yellow';
break;
case 'red-surplus':
label = '🔴 Überschuss'; sub = 'kritisch über Bedarf · ' + pctStr;
cls = 'st-balance-red';
break;
case 'green':
default:
label = '🟢 im Korridor'; sub = '±15 % um den Bedarf';
cls = 'st-balance-green';
break;
}
el.textContent = label;
$('kpiKorridorSub').textContent = sub;
el.classList.remove('st-balance-green', 'st-balance-yellow', 'st-balance-red');
el.classList.add(cls);
}
// ---------- Bedarfs-Graph: Wellenform + Korridor-Band ----------
// Cursor-Position kommt aus state.simProgress (float in Tick-Einheiten),
// nicht aus state.totalTick — dadurch bewegt sich der Cursor pixelweise.
function drawGraph() {
var svg = $('graphSvg');
if (!svg) return;
var W = 1200, H = 240;
var padL = 38, padR = 14, padT = 14, padB = 24;
var innerW = W - padL - padR;
var innerH = H - padT - padB;
// Y-Range: 0 bis Tagesmaximum * Spitzen-Faktor 1.21 + Headroom
var dayMax = Math.max.apply(null, DEMAND_PER_DAY);
var yMax = dayMax * 1.21 * 1.20; // 20 % Headroom über die Spitze
function tx(t) { return padL + (t / TOTAL_TICKS) * innerW; }
function ty(v) { return padT + innerH - (v / yMax) * innerH; }
// Bedarfs- und Einspeisungs-Werte für alle Ticks vorberechnen (deterministic)
var demandVals = [];
var supplyVals = [];
for (var t = 0; t < TOTAL_TICKS; t++) {
var d = Math.floor(t / TICKS_PER_DAY) + 1;
var ti = t % TICKS_PER_DAY;
demandVals.push(demandAtTick(d, ti));
supplyVals.push(supplyAtTick(d, ti));
}
// Bedarfslinie als smooth Path (L-Verbindungen reichen, da 60 Punkte
// bei der Wellenform schon weich aussehen)
var demandPath = '';
var freeSupplyPath = '';
for (var i = 0; i < TOTAL_TICKS; i++) {
demandPath += (i === 0 ? 'M ' : ' L ')
+ tx(i).toFixed(1) + ' ' + ty(demandVals[i]).toFixed(1);
freeSupplyPath += (i === 0 ? 'M ' : ' L ')
+ tx(i).toFixed(1) + ' ' + ty(supplyVals[i]).toFixed(1);
}
// Korridor-Band ±15 % um die Bedarfslinie (geschlossener Pfad)
var corridorPath = '';
// Oberkante (links → rechts)
for (var u = 0; u < TOTAL_TICKS; u++) {
corridorPath += (u === 0 ? 'M ' : ' L ')
+ tx(u).toFixed(1) + ' ' + ty(demandVals[u] * 1.15).toFixed(1);
}
// Unterkante (rechts → links)
for (var lo = TOTAL_TICKS - 1; lo >= 0; lo--) {
corridorPath += ' L '
+ tx(lo).toFixed(1) + ' ' + ty(demandVals[lo] * 0.85).toFixed(1);
}
corridorPath += ' Z';
// Versorgungslinie — nur bis zum aktuellen Tick gezeichnet
var supplyPath = '';
var sup = state.history.supply;
for (var s = 0; s < sup.length; s++) {
supplyPath += (s === 0 ? 'M ' : ' L ')
+ tx(s).toFixed(1) + ' ' + ty(Math.max(0, sup[s])).toFixed(1);
}
// Außerhalb-Korridor-Marker — gelbe/rote Punkte auf der Versorgungslinie
var markerHtml = '';
for (var m = 0; m < sup.length; m++) {
var dem = demandVals[m];
var rel = Math.abs(dem - sup[m]) / dem;
if (rel > 0.15) {
var col = rel > 0.30 ? '#c85c4a' : '#d6a04e';
markerHtml += '<circle cx="' + tx(m).toFixed(1) +
'" cy="' + ty(Math.max(0, sup[m])).toFixed(1) +
'" r="3" fill="' + col + '"/>';
}
}
var html = '';
// Tagesstreifen (abwechselnd)
for (var d = 0; d < TOTAL_DAYS; d++) {
var x1 = tx(d * TICKS_PER_DAY);
var x2 = tx((d + 1) * TICKS_PER_DAY);
var bg = d % 2 === 0 ? '#fafaf6' : '#f0ebde';
html += '<rect x="' + x1.toFixed(1) + '" y="' + padT +
'" width="' + (x2 - x1).toFixed(1) +
'" height="' + innerH + '" fill="' + bg + '"/>';
}
// Y-Achsen-Hilfslinien (zuerst, hinter den Daten)
var yMarks = [0, Math.round(yMax * 0.25), Math.round(yMax * 0.5),
Math.round(yMax * 0.75), Math.round(yMax)];
yMarks.forEach(function (val) {
if (val === 0) return;
var yy = ty(val);
html += '<line x1="' + padL + '" y1="' + yy.toFixed(1) +
'" x2="' + (padL + innerW) + '" y2="' + yy.toFixed(1) +
'" stroke="#d6d1c4" stroke-width="0.5" opacity="0.5"/>';
});
// Korridor-Band
html += '<path d="' + corridorPath +
'" fill="rgba(90,138,94,.20)" stroke="rgba(90,138,94,.40)" stroke-width="0.6"/>';
// Tag-Trenner (vertikale Linien)
for (var dt = 1; dt < TOTAL_DAYS; dt++) {
var dx = tx(dt * TICKS_PER_DAY);
html += '<line x1="' + dx.toFixed(1) + '" y1="' + padT +
'" x2="' + dx.toFixed(1) + '" y2="' + (padT + innerH) +
'" stroke="#c8c4b3" stroke-width="1"/>';
}
// Bedarfslinie (rot, durchgezogen)
html += '<path d="' + demandPath +
'" stroke="#c85c4a" stroke-width="2.6" fill="none" stroke-linejoin="round"/>';
// Freie Einspeisung (Solar/Wind, gelb gestrichelt) — Wellenform unabhängig vom Bedarf
html += '<path d="' + freeSupplyPath +
'" stroke="#e8a82e" stroke-width="2" fill="none" stroke-linejoin="round" ' +
'stroke-dasharray="6 4" opacity="0.85"/>';
// Versorgungslinie (blau, was tatsächlich geliefert wurde — nur Vergangenheit)
if (supplyPath) {
html += '<path d="' + supplyPath +
'" stroke="#1f4e5a" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>';
}
// Außerhalb-Korridor-Marker
html += markerHtml;
// Cursor (jetzt) — pixelweise per state.simProgress
var cp = clamp(state.simProgress, 0, TOTAL_TICKS);
var cx = tx(cp);
// Werte am Cursor — Bedarf interpoliert, Versorgung am letzten Tick
var cpDay = Math.floor(cp / TICKS_PER_DAY) + 1;
var cpTick = cp - Math.floor(cp / TICKS_PER_DAY) * TICKS_PER_DAY;
if (cpDay > TOTAL_DAYS) cpDay = TOTAL_DAYS;
var cursorDemand = demandAtTick(cpDay, cpTick);
var lastSup = sup.length > 0 ? sup[sup.length - 1] : null;
html += '<line x1="' + cx.toFixed(1) + '" y1="' + padT +
'" x2="' + cx.toFixed(1) + '" y2="' + (padT + innerH) +
'" stroke="#4a7c8a" stroke-width="1.6" opacity="0.65"/>';
html += '<circle cx="' + cx.toFixed(1) + '" cy="' +
ty(cursorDemand).toFixed(1) +
'" r="5" fill="#c85c4a" stroke="#fff" stroke-width="1.8"/>';
if (lastSup !== null) {
// Versorgungs-Punkt: läuft auf der bisher gezeichneten Linie mit
// dem Cursor weiter, indem wir bis zum aktuellen X linear extrapolieren
var lastX = tx(sup.length);
var supPointX = Math.min(cx, lastX);
html += '<circle cx="' + supPointX.toFixed(1) + '" cy="' +
ty(Math.max(0, lastSup)).toFixed(1) +
'" r="5" fill="#1f4e5a" stroke="#fff" stroke-width="1.8"/>';
}
// Tag-Beschriftung (unter dem Plot)
for (var dl = 0; dl < TOTAL_DAYS; dl++) {
var lx = tx(dl * TICKS_PER_DAY + TICKS_PER_DAY / 2);
html += '<text x="' + lx.toFixed(1) + '" y="' + (H - 8) +
'" text-anchor="middle" font-size="13" fill="#666" font-weight="600">Tag ' +
(dl + 1) + '</text>';
}
// Y-Achsen-Texte am linken Rand (über dem Plot, oberhalb)
yMarks.forEach(function (val) {
var yy = ty(val);
html += '<text x="' + (padL - 6) + '" y="' + yy.toFixed(1) +
'" font-size="11" fill="#888" text-anchor="end" ' +
'alignment-baseline="middle">' + val + '</text>';
});
svg.innerHTML = html;
}
// ---------- InfoCards (slide-in, nicht-blockierend) ----------
// Inhalts-Definitionen pro Trigger-Key. Audio-File-Namen sind optional —
// gibt es kein audio-Feld, wird nichts abgespielt.
var INFOCARDS = {
anlage: {
tone: '', title: 'Eine Pumpspeicher-Anlage',
body: 'Du steuerst zwei Speicherseen. Pumpspeicher sind ' +
'<b>Zwischenspeicher</b>: wenn Solar oder Wind mehr Strom ' +
'liefern als gerade gebraucht wird, hebst du Wasser nach oben ' +
'(Überschuss speichern). Wenn der Bedarf hochgeht und Solar ' +
'wegfällt, lässt du Wasser ab und turbinierst.',
audio: 'anlage.mp3',
},
pumpe: {
tone: 'tone-pump', title: 'Pumpen — Überschuss in den Speicher',
body: 'Du pumpst Wasser nach oben. Das verbraucht Strom — am besten ' +
'genau dann, wenn Solar mehr liefert als das Netz braucht. ' +
'Sonst verschiebst du Energie zum Schaden des Netzes. ' +
'Wirkungsgrad ~75 %, ein Viertel geht verloren.',
audio: 'pumpe.mp3',
},
turbine: {
tone: 'tone-turbine', title: 'Turbinen — Lücke im Bedarf decken',
body: 'Wasser fließt nach unten, treibt die Turbine, erzeugt Strom. ' +
'Mach das in den Spitzen, wenn Solar und Wind nicht reichen. ' +
'Wirkungsgrad ~85 %, knapp ein Sechstel als Verlust.',
audio: 'turbine.mp3',
},
korridor: {
tone: 'tone-warn', title: 'Netz aus dem Gleichgewicht',
body: 'Zu wenig Strom = <b>Lücke</b> (Verbraucher kriegen nichts). ' +
'Zu viel = <b>Überschuss</b> (Strom geht ungenutzt verloren). ' +
'Beides ist schlecht. Halte das Netz im grünen Korridor ' +
'±15 % um den Bedarf.',
audio: 'korridor.mp3',
},
spitze: {
tone: 'tone-spike', title: 'Heute kommt die Spitze',
body: 'Tag 3 hat den höchsten Bedarf des Durchgangs. Hoffentlich ' +
'hast du oben genug Wasser — sonst kannst du die Spitze ' +
'nicht decken.',
audio: 'spitze.mp3',
},
};
function showInfoCard(key) {
if (state.infocardsShown[key]) return;
var def = INFOCARDS[key];
if (!def) return;
state.infocardsShown[key] = true;
var container = $('stInfocards');
if (!container) return;
var card = document.createElement('div');
card.className = 'st-infocard ' + (def.tone || '');
var html = '';
html += '<div class="st-infocard-body">';
html += '<h3 class="st-infocard-title">';
html += '<span class="st-infocard-title-text">' + def.title + '</span>';
html += '<button class="st-infocard-close" aria-label="schließen">×</button>';
html += '</h3>';
html += '<p class="st-infocard-text">' + def.body + '</p>';
html += '</div>';
card.innerHTML = html;
container.appendChild(card);
// Sliding-in: zwei rAF damit Browser den Initial-Transform anwendet
requestAnimationFrame(function () {
requestAnimationFrame(function () {
card.classList.add('visible');
});
});
var dismissTimer = setTimeout(function () { dismissCard(card); }, 9000);
card.querySelector('.st-infocard-close').addEventListener('click', function () {
clearTimeout(dismissTimer);
dismissCard(card);
});
// Optionale Sprachausgabe — wenn Audio-Datei existiert + nicht stumm
if (def.audio && state.audioEnabled) {
try {
var a = new Audio((window.STAUSTUFEN_BASE || '') + 'sounds/' + def.audio);
a.volume = 0.85;
a.play().catch(function () {});
} catch (e) { /* still */ }
}
}
function dismissCard(card) {
card.classList.remove('visible');
card.classList.add('dismissing');
setTimeout(function () { if (card.parentNode) card.remove(); }, 380);
}
// ---------- Hint-Pop-ups ----------
function showHint(key, title, body) {
if (state.hintsShown[key]) return;
state.hintsShown[key] = true;
state.paused = true;
$('hintTitle').textContent = title;
$('hintBody').innerHTML = body;
$('hintOverlay').classList.add('open');
}
function closeHint() {
$('hintOverlay').classList.remove('open');
state.paused = false;
// Welcome-Hint dismissed → erste InfoCard direkt einblenden
if (state.hintsShown.welcome && !state.infocardsShown.anlage) {
showInfoCard('anlage');
}
}
// ---------- Steuerung ----------
// Stufenlose Steuerung — value ∈ [-100..+100] vom Slider, intern 1..+1.
// Negativ = Pumpe, positiv = Turbine, 0 = aus.
// step="any" am Range-Input → Float-Werte ohne Rasterung.
function setControl(sliderValue) {
var v = clamp(parseFloat(sliderValue) || 0, -100, 100);
var prev = state.control;
state.control = v / 100; // Float, keine Rundung
refreshControlUi();
if (state.control < -0.02 && !state.firstPumpUsed) {
state.firstPumpUsed = true;
showInfoCard('pumpe');
}
if (state.control > 0.02 && !state.firstTurbineUsed) {
state.firstTurbineUsed = true;
showInfoCard('turbine');
}
if (prev !== state.control) render();
}
function refreshControlUi() {
var slider = $('sliderControl');
var readout = $('readoutControl');
if (!slider) return;
// Float-Wert (-100..+100) für Slider, ohne Rundung
var v = state.control * 100;
if (Math.abs(parseFloat(slider.value) - v) > 0.05) slider.value = v;
// Track-Highlight via Klasse + CSS-Variable
var thumbPct = (v + 100) / 2; // 0..100 (% der Track-Breite)
slider.style.setProperty('--st-thumb-pct', thumbPct.toFixed(2) + '%');
slider.classList.remove('mode-pump', 'mode-turbine', 'mode-off');
if (v > 1) slider.classList.add('mode-turbine');
else if (v < -1) slider.classList.add('mode-pump');
else slider.classList.add('mode-off');
if (readout) {
var label;
var absV = Math.round(Math.abs(v)); // Anzeige integer, intern bleibt float
if (absV === 0) label = 'aus';
else if (v > 0) label = 'Turbine ' + absV + ' %';
else label = 'Pumpe ' + absV + ' %';
readout.textContent = label;
readout.classList.toggle('pump', v < -0.5);
readout.classList.toggle('turbine', v > 0.5);
readout.classList.toggle('off', absV === 0);
}
}
function setSpeed(mode) {
if (mode === 'pause') {
state.paused = true;
} else {
state.paused = false;
state.speed = mode;
}
document.querySelectorAll('.st-speed-btn').forEach(function (b) {
b.classList.remove('active');
});
var map = { 'pause':'speedPause', '1x':'speed1x', '4x':'speed4x' };
var elt = $(map[mode]);
if (elt) elt.classList.add('active');
}
// ---------- RAF-Loop: pixelweise Zeit-Achse ----------
// simProgress läuft als float in Tick-Einheiten (0..TOTAL_TICKS).
// Der Cursor liest direkt simProgress, deshalb bewegt er sich smooth.
// Sobald simProgress eine ganze Zahl überschreitet, wird ein Tick verarbeitet.
function rafLoop() {
var now = Date.now();
var dt = now - state.lastFrameMs;
state.lastFrameMs = now;
if (!state.paused && !state.finished) {
var tickMs = SPEED_MS[state.speed] || SPEED_MS['1x'];
state.simProgress += dt / tickMs;
if (state.simProgress > TOTAL_TICKS) state.simProgress = TOTAL_TICKS;
// Tick-Verarbeitung holt die Sprünge ein, falls dt > tickMs (z. B. nach Tab-Wechsel)
while (state.totalTick < Math.floor(state.simProgress)
&& state.totalTick < TOTAL_TICKS) {
tick();
if (state.finished) break;
}
}
drawGraph();
if (!state.finished) {
state.rafId = requestAnimationFrame(rafLoop);
} else {
state.rafId = null;
}
}
function startRaf() {
if (state.rafId) cancelAnimationFrame(state.rafId);
state.lastFrameMs = Date.now();
state.rafId = requestAnimationFrame(rafLoop);
}
// ---------- Bewertung ----------
function evaluate() {
// Netzbalance-Score = Anteil grüner Ticks
var ticksTotal = state.totals.ticksGreen
+ state.totals.ticksYellow
+ state.totals.ticksRed;
var greenPct = ticksTotal > 0
? Math.round((state.totals.ticksGreen / ticksTotal) * 100) : 0;
var yellowPct = ticksTotal > 0
? Math.round((state.totals.ticksYellow / ticksTotal) * 100) : 0;
var redPct = ticksTotal > 0
? Math.round((state.totals.ticksRed / ticksTotal) * 100) : 0;
// Verlust-Quote: losses / waterMoved
var lossPct = state.totals.waterMoved > 0
? state.totals.losses / state.totals.waterMoved
: 0;
var grade = 'E';
if (greenPct >= 90 && lossPct <= 0.22) grade = 'A';
else if (greenPct >= 78 && lossPct <= 0.30) grade = 'B';
else if (greenPct >= 60) grade = 'C';
else if (greenPct >= 40) grade = 'D';
// Narrative: schaut sich Lücke vs. Überschuss + Verluste an
var unmet = state.totals.unmet;
var surplus = state.totals.surplus;
var dominantSide = unmet > surplus ? 'deficit'
: surplus > unmet ? 'surplus' : 'balanced';
var narrative = '';
if (grade === 'A') {
narrative = 'Das Netz lief fast durchgehend im grünen Korridor — ' +
'sauber gepumpt, sauber turbiniert. Spitze und Tal sind zueinander ' +
'in Balance gebracht worden.';
} else if (grade === 'B') {
narrative = 'Das Netz war meistens stabil. ' +
(dominantSide === 'surplus'
? 'In manchen Phasen lief etwas zu viel — Strom geht ungenutzt ' +
'verloren, wenn Erzeugung den Bedarf zu deutlich übersteigt.'
: dominantSide === 'deficit'
? 'In manchen Phasen war zu wenig Strom verfügbar — ' +
'Verbraucher bekamen weniger als sie brauchten.'
: 'Mit etwas weniger Hin-und-Her wäre A drin gewesen.');
} else if (grade === 'C') {
narrative = 'Das Netz hat öfter den grünen Korridor verlassen. ' +
(dominantSide === 'surplus'
? 'Häufig lief Turbine oder freie Einspeisung über den Bedarf — ' +
'das ist Verschwendung. Tipp: Turbine nur so stark, wie der ' +
'Bedarf wirklich braucht.'
: 'Häufig blieb der Bedarf ungedeckt. Tipp: in den niedrigen ' +
'Tagen 1 und 4 oben Wasser sammeln, damit Tag 3 und 5 ' +
'durchgehalten werden.');
} else if (grade === 'D') {
narrative = 'Das Netz war oft im roten Bereich. ' +
'Strom-Überschuss und -Lücke beide vermeiden: Pumpe nur wenn ' +
'wirklich Überschuss da ist, Turbine nur so stark wie Bedarf.';
} else {
narrative = 'Das System wurde mehrfach aus dem Gleichgewicht ' +
'gebracht. Das Netz braucht ständige Ausbalancierung — schau dir ' +
'die Bedarfskurve genau an und reagiere mit der passenden Stufe.';
}
return {
greenPct: greenPct,
yellowPct: yellowPct,
redPct: redPct,
losses: state.totals.losses,
unmet: state.totals.unmet,
surplus: state.totals.surplus,
lossPct: Math.round(lossPct * 100),
grade: grade,
narrative: narrative,
dominantSide: dominantSide,
};
}
// ---------- Ende ----------
function finish() {
if (state.finished) return;
state.finished = true;
// RAF-Loop hört von selbst auf (wegen state.finished), keine clearInterval nötig
if (state.assessment.pingTimer) {
clearInterval(state.assessment.pingTimer);
state.assessment.pingTimer = null;
}
var r = evaluate();
$('endNetzbalance').textContent = r.greenPct + ' %';
$('endVerluste').textContent = fmt(r.losses, 0) + ' GWh';
var deviationLabel = '';
var deviation = 0;
if (r.dominantSide === 'deficit') {
deviationLabel = 'Lücke';
deviation = r.unmet;
} else if (r.dominantSide === 'surplus') {
deviationLabel = 'Überschuss';
deviation = r.surplus;
} else {
deviationLabel = 'Abweichung';
deviation = Math.max(r.unmet, r.surplus);
}
$('endDeviation').textContent = fmt(deviation, 0) + ' GWh';
$('endDeviationLabel').textContent = deviationLabel;
var endGrade = $('endGrade');
endGrade.textContent = r.grade;
endGrade.className = 'st-endscreen-grade ' + r.grade;
var endCard = $('endCard');
endCard.classList.remove('success', 'failed');
if (r.grade === 'A' || r.grade === 'B') {
endCard.classList.add('success');
$('endIcon').textContent = '🏆';
$('endTitle').textContent = 'Erfolgreich durchgeführt';
} else if (r.grade === 'D' || r.grade === 'E') {
endCard.classList.add('failed');
$('endIcon').textContent = '⚠️';
$('endTitle').textContent = 'Mit Lücken durchgeführt';
} else {
$('endIcon').textContent = '🏞️';
$('endTitle').textContent = 'Durchgang abgeschlossen';
}
$('endSubtitle').textContent =
'5 Tage Netzbalance bilanziert — ' +
fmt(state.totals.turbineSupplied, 0) + ' GWh aus Turbinen, ' +
fmt(state.totals.pumpDrawn, 0) + ' GWh in Pumpspeicher.';
$('endNarrative').textContent = r.narrative;
$('endOverlay').style.display = 'flex';
assessmentComplete(r);
}
function restart() {
// Komplett zurücksetzen
state.day = 1; state.tickInDay = 0; state.totalTick = 0;
state.paused = false; state.finished = false;
state.tankTop = INIT_TOP; state.tankBot = INIT_BOT;
state.control = 0;
state.firstPumpUsed = false;
state.firstTurbineUsed = false;
state.lastTurbineWater = 0; state.lastPumpWater = 0;
state.lastTurbineElec = 0; state.lastPumpElec = 0;
state.totals = {
demand: 0, freeSupplyTotal: 0,
turbineSupplied: 0, pumpDrawn: 0,
unmet: 0, surplus: 0, losses: 0, waterMoved: 0,
ticksGreen: 0, ticksYellow: 0, ticksRed: 0,
};
state.lastBalance = 'green';
state.lastGapRel = 0;
state.history = { supply: [] };
state.hintsShown = { welcome: false, imbalance: false };
state.infocardsShown = {
anlage: false, pumpe: false, turbine: false,
korridor: false, spitze: false,
};
var ic = $('stInfocards'); if (ic) ic.innerHTML = '';
state.assessment.startMs = null;
state.assessment.sentStarted = false;
state.assessment.sentCompleted = false;
refreshControlUi();
state.simProgress = 0;
state.lastFrameMs = Date.now();
setSpeed('1x');
startRaf();
$('endOverlay').style.display = 'none';
render();
showWelcomeHint();
assessmentStart();
}
// ---------- Assessment-API ----------
function assessmentSend(payload) {
try {
if (!window.STAUSTUFEN_SESSION_ID) return; // Demo-Modus
var url = (window.STAUSTUFEN_API_BASE || '/api') + '/assessment';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
}).catch(function () {});
} catch (e) { /* still */ }
}
function assessmentStart() {
state.assessment.startMs = Date.now();
state.assessment.sentStarted = true;
assessmentSend({
simId: 'staustufen',
processLog: {
phase: 'started',
forcedLevel: window.STAUSTUFEN_FORCED_LEVEL || 1,
mode: window.STAUSTUFEN_SESSION_MODE || 'free',
easy: !!window.STUDENT_EASY,
},
duration: 0,
completedPhases: [],
});
if (state.assessment.pingTimer) clearInterval(state.assessment.pingTimer);
state.assessment.pingTimer = setInterval(assessmentPing, 30000);
}
function assessmentPing() {
if (!state.assessment.startMs || state.finished) return;
var dt = Date.now() - state.assessment.startMs;
assessmentSend({
simId: 'staustufen',
processLog: {
phase: 'running',
day: state.day,
tickTotal: state.totalTick,
tankTopPct: Math.round(state.tankTop / TANK_CAP * 100),
tankBotPct: Math.round(state.tankBot / TANK_CAP * 100),
control: Math.round(state.control * 100), // -100..+100, stufenlos
ticksGreen: state.totals.ticksGreen,
ticksYellow: state.totals.ticksYellow,
ticksRed: state.totals.ticksRed,
unmetSoFar: Math.round(state.totals.unmet),
surplusSoFar: Math.round(state.totals.surplus),
lossesSoFar: Math.round(state.totals.losses),
},
duration: dt,
completedPhases: ['day' + state.day],
});
}
function assessmentComplete(result) {
if (state.assessment.sentCompleted) return;
state.assessment.sentCompleted = true;
var dt = state.assessment.startMs
? Date.now() - state.assessment.startMs : 0;
assessmentSend({
simId: 'staustufen',
processLog: {
phase: 'completed',
finalDay: TOTAL_DAYS,
},
results: {
grade: result.grade,
netzbalance: result.greenPct, // Anteil grüner Ticks
ticksGreen: state.totals.ticksGreen,
ticksYellow: state.totals.ticksYellow,
ticksRed: state.totals.ticksRed,
losses: Math.round(result.losses),
lossPct: result.lossPct,
unmet: Math.round(result.unmet),
surplus: Math.round(result.surplus),
dominantSide: result.dominantSide,
turbineSupplied: Math.round(state.totals.turbineSupplied),
pumpDrawn: Math.round(state.totals.pumpDrawn),
},
duration: dt,
completedPhases: ['day1','day2','day3','day4','day5','results'],
});
}
// ---------- Welcome-Hint ----------
function showWelcomeHint() {
showHint('welcome',
'Willkommen in der Kaskade.',
'Im Netz schwankt beides: der <b>Bedarf</b> (rote Linie) und die ' +
'<b>freie Einspeisung</b> aus Solar und Wind (gelbe Linie). ' +
'Wenn die gelbe Linie über der roten liegt — <b>Überschuss</b>, du ' +
'kannst <b>pumpen</b> und Wasser für später nach oben heben. ' +
'Wenn sie darunter liegt — <b>Lücke</b>, du musst <b>turbinieren</b> ' +
'und mit gespeichertem Wasser auffüllen. Halte das Netz im ' +
'grünen Korridor ±15 % um den Bedarf — beide Seiten sind schlecht.');
}
// ---------- Init ----------
function init() {
// Steuerung — bidirektionaler Slider (-100..+100, stufenlos)
var ctrl = $('sliderControl');
if (ctrl) {
ctrl.addEventListener('input', function (e) { setControl(e.target.value); });
ctrl.addEventListener('change', function (e) { setControl(e.target.value); });
}
// Doppel-Tap auf den Mittelpunkt → zurück auf 0 (Notbremse)
var ctrlZero = $('btnControlZero');
if (ctrlZero) {
ctrlZero.addEventListener('click', function () { setControl(0); });
}
// Tempo
$('speedPause').addEventListener('click', function () { setSpeed('pause'); });
$('speed1x').addEventListener('click', function () { setSpeed('1x'); });
$('speed4x').addEventListener('click', function () { setSpeed('4x'); });
// Hint
$('hintClose').addEventListener('click', closeHint);
$('hintOverlay').addEventListener('click', function (e) {
if (e.target === $('hintOverlay')) closeHint();
});
// Restart
$('endRestart').addEventListener('click', restart);
// Audio-Toggle (Sprachausgabe)
var audioBtn = $('btnAudioToggle');
if (audioBtn) {
audioBtn.addEventListener('click', function () {
state.audioEnabled = !state.audioEnabled;
audioBtn.classList.toggle('muted', !state.audioEnabled);
audioBtn.textContent = state.audioEnabled ? '🔊' : '🔇';
audioBtn.setAttribute('aria-pressed',
state.audioEnabled ? 'true' : 'false');
audioBtn.title = state.audioEnabled
? 'Sprachausgabe aus' : 'Sprachausgabe an';
});
}
// Erst-Render
render();
// Welcome-Hint sofort beim Start (Pause)
showWelcomeHint();
// Assessment + Smooth-RAF-Loop
assessmentStart();
startRaf();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();