Files
Adminator 47adf0497d Klima: Geflutet an Meeresspiegel koppeln (kein Deckel-Plateau) -> Zahl passt zur Karte
floodedPct = sea*0.45 + max(0,sea-prot)*1.1. Vorher deckelte der Strand-Anteil bei
12%, sobald Schutz das Inland trocken hielt -> alle Spieler zeigten 12% egal wie das
Klima stand. Die Karte zeichnet die Flut aber aus seaLevelCm -> Zahl und Bild liefen
auseinander. Jetzt steigt die Zahl stetig mit dem Meeresspiegel (Klimafolge), Schutz
daempft nur den Zusatzschaden. Node-validiert: gutes Klima=niedrige Flut, hohe Temp=
hohe Flut, gleiche-Tick-Spreizung statt Plateau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:43:58 +02:00

899 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/**
* Klima-Wächter — Engine (Single Source of Truth für 2D und 3D)
* ------------------------------------------------------------------
* Headless Spiellogik: kein DOM, keine kwAudio-Aufrufe, keine Renderings.
* Konsumenten (game-2d.html, game-3d.html) rufen die Engine-Methoden auf
* und verarbeiten die zurückgegebenen Events (Toast, Sound) selbst.
*
* Exportiert als globales `window.KlimaEngine` (kein ES-Module, kein Bundler).
*
* Die Engine-Funktionen sind zustandsfrei gegenüber dem Modul — sie operieren
* auf einem `game`-Objekt, das du mit `createGame(levelId)` erzeugst.
*
* Event-Pattern: mutierende Aktionen (buyMeasure, tick, applyCitizenChoice)
* pushen fire-and-forget Events in einen internen Puffer `game._pendingEvents`
* und geben beim Aufruf die in DIESEM Aufruf frisch hinzugekommenen Events
* zurück, damit die View Sound/Toast ansteuern kann. Alle Events werden
* ausserdem permanent in `game.events` (Event-Stapel) gehalten.
*/
(function () {
'use strict';
/* ============================================================
KONSTANTEN — Maßnahmen, Schwierigkeit, Achievements, Events
============================================================ */
const MEASURES = [
{ id:'forest', name:'Wald aufforsten', emoji:'🌲', description:'Bäume binden langfristig CO₂. Sehr günstig im Unterhalt — die Naturlösung.',
cost:12, upkeep:0.5, co2Reduction:0.04, protection:0 },
{ id:'solar', name:'Solaranlage', emoji:'☀️', description:'Klein und günstig — sofort kaufbar. Wartung ist mittelhoch.',
cost:40, upkeep:2, co2Reduction:0.14, protection:0, powerOutput:1 },
{ id:'wind', name:'Windpark', emoji:'🌬️', description:'Großes Windrad — hohe Anschaffung, dafür viel sauberer Strom.',
cost:150, upkeep:4, co2Reduction:0.50, protection:0, powerOutput:3 },
{ id:'green-roof', name:'Gründächer', emoji:'🏡', description:'Begrünte Dächer kühlen die Stadt. Keine jährlichen Kosten.',
cost:30, upkeep:0, co2Reduction:0.04, protection:0 },
{ id:'bikes', name:'Radwege-Netz', emoji:'🚲', description:'Macht die Insel fahrradfreundlich. Günstig, kein Strom nötig.',
cost:18, upkeep:0.5, co2Reduction:0.06, protection:0 },
{ id:'mangrove', name:'Mangrovenwald', emoji:'🪸', description:'Bindet CO₂ UND schützt die Küste vor Erosion — beides in einem.',
cost:35, upkeep:1, co2Reduction:0.06, protection:6 },
{ id:'sand-fill', name:'Sand-Aufschüttung', emoji:'🏖', description:'Künstlicher Strand. Sofort Schutz — wird aber jährlich vom Meer weggespült.',
cost:50, upkeep:0, co2Reduction:0, protection:18 },
{ id:'dike', name:'Deich bauen', emoji:'🌊', description:'Niedrige Hürde — schnell baubar, aber teuer pro cm Schutz.',
cost:80, upkeep:3, co2Reduction:0, protection:12 },
{ id:'sea-wall', name:'Hochwasserschutz', emoji:'🛡️', description:'Teuer, aber deutlich effizienter pro Mio €. Sehr starker Schutz.',
cost:240, upkeep:6, co2Reduction:0, protection:50 },
{ id:'coal', name:'Kohlekraftwerk', emoji:'🏭', description:'Liefert viel Strom — billig und sofort. Aber viel CO₂-Ausstoß!',
cost:20, upkeep:1, co2Reduction:-0.38, protection:0, powerOutput:4 },
{ id:'airport', name:'Tourismus-Flughafen', emoji:'✈️', description:'Bringt Touristen — aber Flugverkehr bedeutet viel CO₂.',
cost:62, upkeep:3, co2Reduction:-0.22, protection:0 },
{ id:'cloud-seed', name:'Wolken-Impfung', emoji:'🌤️', description:'Geo-Engineering. Klingt nach Wundermittel — wirkt aber kaum.',
cost:125, upkeep:4.5, co2Reduction:0.04, protection:0 },
];
const DIFFICULTY = {
1: { id:1, label:'Lernen', emoji:'🟢',
startBudget:600, startPopulation:6000, incomePer10k:220,
blackoutGrace:10, treesPerBlackout:1, co2BlackoutPerYear:0.15,
climateDamageMul:0.35, popLossMul:0.35,
skipCitizenEvents:['citizen-scientist','citizen-farmer','citizen-tourism','citizen-youth','citizen-industry','citizen-mountain'],
citizenEventTickOffset:20 },
2: { id:2, label:'Üben', emoji:'🟡',
startBudget:480, startPopulation:6000, incomePer10k:155,
blackoutGrace:0, treesPerBlackout:1, co2BlackoutPerYear:0.4,
climateDamageMul:1.2, popLossMul:1.1 },
3: { id:3, label:'Profi', emoji:'🔴',
startBudget:220, startPopulation:7000, incomePer10k:110,
blackoutGrace:0, treesPerBlackout:2, co2BlackoutPerYear:0.7,
climateDamageMul:2.2, popLossMul:1.8 }
};
const ACHIEVEMENTS = [
{ key:'first_build', icon:'🔨', title:'Erste Maßnahme', desc:'Deine erste Klimaschutz-Maßnahme gebaut.' },
{ key:'first_wind', icon:'🌬️', title:'Windkraft-Pionier', desc:'Ersten Windpark errichtet.' },
{ key:'first_coast', icon:'🛡️', title:'Küstenschützer*in', desc:'Deich oder Hochwasserschutz gebaut.' },
{ key:'renewable_100', icon:'☀️', title:'100 % Erneuerbar', desc:'Keine fossilen Kraftwerke, Stromdeckung gesichert.' },
{ key:'under_1_5', icon:'🌍', title:'Unter 1,5 °C', desc:'Temperatur bleibt im Pariser Zielkorridor.' },
{ key:'forester', icon:'🌲', title:'Baummeister*in', desc:'Mindestens 10 Wald-Aufforstungen.' },
{ key:'survivor', icon:'🏆', title:'Überlebenskünstler*in', desc:'Das Jahr 2175 erreicht.' },
{ key:'frugal', icon:'💰', title:'Sparfuchs', desc:'Am Ende noch > 100 Mio € übrig.' },
];
// Aufklärungs-Events bei zweifelhaften Maßnahmen, einmal pro Maßnahme.
const FIRST_BUY_HINTS = {
'cloud-seed': { icon:'⚠️', text:'Wolken-Impfung klingt groß — Forschung zeigt: kaum Klima-Wirkung. Klick für Details.', infoKey:'cloud_seeding' },
'coal': { icon:'🏭', text:'Kohle liefert schnell Strom — aber jede Tonne Kohle = sehr viel CO₂. Klick für Details.', infoKey:'coal_power' },
'airport': { icon:'✈️', text:'Flughafen bringt Geld — Flugverkehr ist aber sehr CO₂-intensiv. Klick für Details.', infoKey:'aviation' },
};
const CITIZEN_EVENTS = [
{ id:'citizen-fisher', tick:30, cond:g => true,
char:'🎣', name:'Lina, Fischerin',
msg:'Bürgermeister*in! Das Wasser wird wärmer, die Fische ziehen weg. Mein Boot ist alt — wir brauchen Hilfe, oder wir müssen aufgeben.',
choices:[
{ title:'120 Mio € für moderne Boote', desc:'Bevölkerung stabil. Budget 120 Mio €.',
apply:(g, ctx) => { g.budget -= 120; } },
{ title:'Auf Tourismus umstellen', desc:'Kostenlos, Extra-Einnahmen solange Strand. Mehr CO₂. Strand weg → Kollaps.',
apply:(g, ctx) => { g.population -= 200; g.tourismMode = true; ctx.pushEvent('🏖','Die Insel stellt sich auf Tourismus um.','neutral'); } },
{ title:'Nichts tun', desc:'400 Bevölkerung, aber kein Geldverlust.',
apply:(g, ctx) => { g.population -= 400; } },
] },
{ id:'citizen-scientist', tick:18, cond:g => true,
char:'👩‍🔬', name:'Dr. Hassan, Klimaforscherin',
msg:'Wir haben ein neues Verfahren entwickelt — mit einem Forschungszentrum werden alle künftigen Maßnahmen 20 % billiger.',
choices:[
{ title:'Forschungszentrum bauen (300 Mio €)', desc:'Alle künftigen Maßnahmen 20 % billiger.',
apply:(g, ctx) => { g.budget -= 300; g.researchDiscount = 0.8; } },
{ title:'Antrag ablehnen', desc:'Nichts passiert.',
apply:(g, ctx) => {} },
] },
{ id:'citizen-farmer', tick:28, cond:g => g.currentTemp > 15.8,
char:'👨‍🌾', name:'Yusuf, Bauer',
msg:'Die Felder vertrocknen! Letztes Jahr hatten wir kaum Ernte. Brauchen wir Bewässerung?',
choices:[
{ title:'Bewässerungssystem bauen (180 Mio €)', desc:'Bevölkerung stabil.',
apply:(g, ctx) => { g.budget -= 180; } },
{ title:'Salzresistente Sorten subventionieren (60 Mio €)', desc:'100 Menschen, langfristig stabiler.',
apply:(g, ctx) => { g.budget -= 60; g.population -= 100; } },
{ title:'Nichts tun', desc:'500 Menschen ziehen weg.',
apply:(g, ctx) => { g.population -= 500; } },
] },
{ id:'citizen-tourism', tick:40, cond:g => g.seaLevelCm > 15,
char:'🏖️', name:'Maria, Hotelbesitzerin',
msg:'Der Strand wird kleiner. Meine Hotels stehen halbleer. Wir müssen etwas gegen den Meeresspiegel tun!',
choices:[
{ title:'Strand künstlich aufschütten (50 Mio €)', desc:'+5 cm Küstenschutz.',
apply:(g, ctx) => { g.budget -= 50; g.protection += 5; g.hasSandBeach = true; } },
{ title:'„Wir können das Meer nicht aufhalten"', desc:'300 Menschen wandern ab.',
apply:(g, ctx) => { g.population -= 300; } },
] },
{ id:'citizen-youth', tick:56, cond:g => g.co2Reduction < 1,
char:'👧', name:'Lia, 14 Jahre',
msg:'Sie planen unsere Zukunft! Wir haben heute gestreikt. Machen Sie endlich ernst mit dem Klimaschutz.',
choices:[
{ title:'„Ich verspreche, mehr zu tun" — 1 Solar gratis', desc:'+1 kostenlose Solaranlage.',
apply:(g, ctx) => {
if (!g.ownedMeasures.solar) g.ownedMeasures.solar = { count:0, instances:[] };
g.ownedMeasures.solar.count++;
const extra = ctx.resolveMeta ? ctx.resolveMeta('solar') : null;
g.ownedMeasures.solar.instances.push(Object.assign({ builtAt: g.tick }, extra || {}));
recalcEffects(g);
} },
{ title:'Streik aussitzen', desc:'150 Bevölkerung (Jugend wandert ab).',
apply:(g, ctx) => { g.population -= 150; } },
] },
{ id:'citizen-industry', tick:76, cond:g => true,
char:'🏭', name:'Konzernchef Vogel',
msg:'Wir bringen Arbeitsplätze — wenn Sie uns Steuererleichterungen geben. Sonst wandern wir ab.',
choices:[
{ title:'Subventionen zahlen (250 Mio €)', desc:'+500 Bevölkerung, +5 ppm CO₂.',
apply:(g, ctx) => { g.budget -= 250; g.population += 500; g.co2Ppm += 5; } },
{ title:'Klar nein', desc:'200 Bevölkerung verlässt die Insel.',
apply:(g, ctx) => { g.population -= 200; } },
{ title:'Nur klimaneutral', desc:'Konzern lehnt ab, nichts passiert.',
apply:(g, ctx) => {} },
] },
{ id:'citizen-mountain', tick:100, cond:g => g.currentTemp > 16.5,
char:'🏔', name:'Anna vom Bergdorf',
msg:'Der Gletscher schmilzt — bei Starkregen fließt das Wasser in unser Dorf. Wir brauchen Schutzwälle!',
choices:[
{ title:'Lawinen-Schutzwall bauen (55 Mio €)', desc:'+4 cm Küstenschutz, Dorf sicher.',
apply:(g, ctx) => { g.budget -= 55; g.protection += 4; g.hasMountainShield = true; } },
{ title:'Bergdorf evakuieren', desc:'400 Menschen, aber Geld gespart.',
apply:(g, ctx) => { g.population -= 400; } },
] },
];
const CO2_FLOOR = 350;
const TEMP_FLOOR = 15.0;
/* ============================================================
HILFEN
============================================================ */
function findMeasure(id) { return MEASURES.find(m => m.id === id); }
function pushEvent(game, icon, text, type, infoKey) {
const ev = { icon, text, type: type || 'neutral', tick: game.tick, infoKey: infoKey || null };
game.events.unshift(ev);
if (game.events.length > 40) game.events.pop();
game._pendingEvents.push(ev);
return ev;
}
function eventFiredOnce(game, id) {
if (game.firedEvents.has(id)) return true;
game.firedEvents.add(id);
return false;
}
/* ============================================================
KLIMA-PHYSIK
============================================================ */
function computeTemperatureFromCO2(co2ppm) {
const PRE = 280, CS = 3.0;
const SOLAR = 1361, STEF = 5.67e-8, ALBEDO = 0.3;
const absorbed = (SOLAR / 4) * (1 - ALBEDO);
const tempNoGH = Math.pow(absorbed / STEF, 0.25) - 273.15;
const NATURAL_GH = 33;
return tempNoGH + NATURAL_GH + CS * Math.log2(co2ppm / PRE);
}
function computeClimate(game) {
game.targetTemp = computeTemperatureFromCO2(game.co2Ppm);
if (game.currentTemp <= 0) game.currentTemp = game.targetTemp;
else game.currentTemp += (game.targetTemp - game.currentTemp) * 0.08;
if (game.currentTemp < TEMP_FLOOR) game.currentTemp = TEMP_FLOOR;
const deltaT = Math.max(0, game.currentTemp - 15);
// Schneller Anteil (thermische Ausdehnung + schnelle Gletscherschmelze)
const fastTarget = deltaT * 15;
if (fastTarget > game.seaFastCm) game.seaFastCm += (fastTarget - game.seaFastCm) * 0.04;
else game.seaFastCm += (fastTarget - game.seaFastCm) * 0.003;
if (game.seaFastCm < 0) game.seaFastCm = 0;
// Committed sea level rise (Eisschilde, Tiefenozean — irreversibel)
game.committedSeaCm += deltaT * 0.15;
game.seaLevelCm = game.seaFastCm + game.committedSeaCm;
// Geflutet-Anteil = sichtbare Wirkung auf der Karte. STETIG mit dem
// Meeresspiegel — KEIN Deckel-Plateau, damit der Wert die Klimaleistung
// widerspiegelt (nicht nur „hat Deiche gebaut"):
// • Grundanteil (Strand/Küste) steigt direkt mit dem Meeresspiegel und
// lässt sich NICHT wegdeichen — er ist die unvermeidbare Folge der
// Erwärmung. Niedrige Temperatur → niedriger Meeresspiegel → wenig Flut.
// • Zusatzschaden dort, wo der Anstieg den Schutz ÜBERRENNT (effRise):
// Deiche/Mauern verzögern das Absaufen des Inlands, halten aber nicht
// ewig. So hilft Schutz spürbar, ersetzt aber keinen Klimaschutz.
const effRise = Math.max(0, game.seaLevelCm - game.protection);
game.floodedPct = Math.round(Math.min(100, game.seaLevelCm * 0.45 + effRise * 1.1));
}
/* ============================================================
ABGELEITETE WERTE
============================================================ */
function recalcEffects(game) {
let co2Red=0, prot=0, upkeep=0, power=0, renewablePower=0;
for (const id in game.ownedMeasures) {
const m = findMeasure(id);
if (!m) continue;
const entry = game.ownedMeasures[id];
co2Red += m.co2Reduction * entry.count;
upkeep += m.upkeep * entry.count;
power += (m.powerOutput || 0) * entry.count;
if (id !== 'coal') renewablePower += (m.powerOutput || 0) * entry.count;
if (id === 'sand-fill') {
for (const inst of entry.instances) {
const age = Math.max(0, game.tick - inst.builtAt);
prot += Math.max(0, m.protection - age * 2);
}
} else {
prot += m.protection * entry.count;
}
}
game.co2Reduction = co2Red;
game.protection = prot;
game.upkeepTotal = upkeep;
game.powerOutput = power;
game.renewablePower = renewablePower;
}
function getEmissionsThisYear(game) {
const startEm = 2.5, peakEm = 3.2, ramp = 50;
if (game.tick < ramp) return startEm + (peakEm - startEm) * (game.tick / ramp);
return peakEm;
}
function getPowerDemand(game) {
let demand = Math.max(0, Math.round(game.population / 1000));
const airport = game.ownedMeasures.airport;
if (airport && airport.count > 0) demand += airport.count * 3;
const cs = game.ownedMeasures['cloud-seed'];
if (cs && cs.count > 0) demand += cs.count * 2;
return demand;
}
function getIncomeThisYear(game) {
const popRatio = game.population / 10000;
return Math.round(game.diff.incomePer10k * popRatio);
}
function getYearlyBalance(game) {
const popRatio = game.population / 10000;
const income = Math.round(game.diff.incomePer10k * popRatio);
const tourism = (game.tourismMode && game.seaLevelCm < 40) ? Math.round(35 * popRatio) : 0;
const upkeep = game.upkeepTotal;
let climate = 0;
if (game.seaLevelCm > 25) {
const sev = Math.min(1, (game.seaLevelCm - 25) / 40);
climate = Math.round(sev * 12 * game.diff.climateDamageMul);
}
return { income, tourism, upkeep, climate, net: income + tourism - upkeep - climate };
}
/* ============================================================
ACHIEVEMENTS
============================================================ */
function tryUnlockAchievement(game, key) {
if (game.achievements.has(key)) return null;
const a = ACHIEVEMENTS.find(x => x.key === key);
if (!a) return null;
game.achievements.add(key);
game._pendingAchievements.push(a);
return a;
}
function checkRenewable100(game) {
if (game.achievements.has('renewable_100')) return;
const hasCoal = game.ownedMeasures.coal && game.ownedMeasures.coal.count > 0;
const hasAir = game.ownedMeasures.airport && game.ownedMeasures.airport.count > 0;
if (hasCoal || hasAir) return;
const demand = getPowerDemand(game);
if (game.powerOutput >= demand && demand > 0 && game.tick >= 5) tryUnlockAchievement(game, 'renewable_100');
}
/* ============================================================
BEVÖLKERUNGSWACHSTUM
============================================================ */
function computePopulationGrowth(game) {
let measureCount = 0;
for (const id in game.ownedMeasures) measureCount += game.ownedMeasures[id].count;
const capBonus = Math.min(1.0, measureCount * 0.05);
const popCap = game.diff.startPopulation * (1 + capBonus);
if (game.population >= popCap) return;
const tempFactor = game.currentTemp < 16.5 ? 1 : game.currentTemp < 17.5 ? 0.4 : 0;
const floodFactor = game.floodedPct < 15 ? 1 : game.floodedPct < 35 ? 0.5 : 0;
const demand = getPowerDemand(game);
const powerFactor = demand === 0 ? 1 : (game.powerOutput >= demand ? 1 : 0.3);
const budgetFactor = game.budget > 0 ? 1 : 0.2;
const attractiveness = tempFactor * floodFactor * powerFactor * budgetFactor;
if (attractiveness <= 0) return;
const gap = popCap - game.population;
const growth = Math.round(gap * 0.02 * attractiveness);
if (growth > 0) game.population += growth;
}
/* ============================================================
AKTIONEN — kaufen, abreissen
============================================================ */
/**
* buyMeasure(game, id, options = { resolveMeta, forceNegBalance })
* Rückgabe:
* { ok: true, cost, events: [...], unlockedAchievements: [...],
* firstBuyHint: { icon, text, type, infoKey } | null }
* { ok: false, reason: 'not-enough-budget' | 'unknown-measure' | 'negative-balance' }
*
* Bei `negative-balance` entscheidet die View (Confirm-Dialog) und ruft
* `buyMeasure(game, id, { forceNegBalance: true, resolveMeta })` erneut.
*/
function buyMeasure(game, id, options) {
options = options || {};
const m = findMeasure(id);
if (!m) return { ok: false, reason: 'unknown-measure' };
const cost = Math.round(m.cost * game.researchDiscount);
if (game.budget < cost) {
return { ok: false, reason: 'not-enough-budget', cost };
}
// Einmalige Negativ-Bilanz-Warnung — View muss confirmieren
if (!game.negBalanceWarningShown && !options.forceNegBalance) {
const bal = getYearlyBalance(game);
const netAfter = bal.income + bal.tourism - (bal.upkeep + m.upkeep) - bal.climate;
if (netAfter < 0) {
return { ok: false, reason: 'negative-balance', cost, netAfter, measure: m };
}
}
if (options.forceNegBalance) game.negBalanceWarningShown = true;
game._beginEvents();
game._beginAchievements();
game.budget -= cost;
if (!game.ownedMeasures[id]) game.ownedMeasures[id] = { count:0, instances:[] };
game.ownedMeasures[id].count++;
const extra = options.resolveMeta ? options.resolveMeta(id) : null;
game.ownedMeasures[id].instances.push(Object.assign({ builtAt: game.tick }, extra || {}));
game.actionLog.push({ tick: game.tick, action:'buy', item:id, cost });
recalcEffects(game);
const evType = m.co2Reduction >= 0.05 ? 'good' : (m.co2Reduction < 0 ? 'bad' : 'neutral');
pushEvent(game, m.emoji, m.name + ' gebaut (' + cost + ' Mio €)', evType);
if (!game.achievements.has('first_build')) tryUnlockAchievement(game, 'first_build');
if (id === 'wind' && !game.achievements.has('first_wind')) tryUnlockAchievement(game, 'first_wind');
if ((id === 'dike' || id === 'sea-wall' || id === 'mangrove') && !game.achievements.has('first_coast')) tryUnlockAchievement(game, 'first_coast');
if (id === 'forest' && game.ownedMeasures.forest && game.ownedMeasures.forest.count >= 10 && !game.achievements.has('forester')) tryUnlockAchievement(game, 'forester');
checkRenewable100(game);
// First-Buy-Hint bei zweifelhaften Maßnahmen (einmal pro Maßnahme)
let firstBuyHint = null;
if (game.ownedMeasures[id].count === 1) {
const trap = FIRST_BUY_HINTS[id];
if (trap && !eventFiredOnce(game, 'first-buy-' + id)) {
firstBuyHint = trap;
pushEvent(game, trap.icon, trap.text, 'bad', trap.infoKey);
}
}
return {
ok: true,
cost,
measure: m,
events: game._drainEvents(),
unlockedAchievements: game._drainAchievements(),
firstBuyHint,
};
}
function demolishMeasure(game, id) {
const m = findMeasure(id);
if (!m) return { ok: false, reason: 'unknown-measure' };
const entry = game.ownedMeasures[id];
if (!entry || entry.count <= 0) return { ok: false, reason: 'nothing-to-demolish' };
game._beginEvents();
entry.count--;
entry.instances.pop();
if (entry.count === 0) delete game.ownedMeasures[id];
const refund = Math.round(m.cost * game.researchDiscount * 0.5);
game.budget += refund;
game.actionLog.push({ tick: game.tick, action:'demolish', item:id, refund });
recalcEffects(game);
pushEvent(game, '🗑', m.name + ' abgerissen (+' + refund + ' Mio €)', 'neutral');
return {
ok: true,
refund,
events: game._drainEvents(),
};
}
/* ============================================================
BÜRGER-EVENTS
============================================================ */
function maybeCitizenEvent(game) {
if (game.pendingCitizenEventId) return null;
const offset = game.diff.citizenEventTickOffset || 0;
const skipped = new Set(game.diff.skipCitizenEvents || []);
for (const ev of CITIZEN_EVENTS) {
if (skipped.has(ev.id)) continue;
if (game.citizenEventsFired.has(ev.id)) continue;
if (game.tick !== ev.tick + offset) continue;
if (!ev.cond(game)) continue;
// Pending markieren, damit der gleiche Event nicht im nächsten Tick
// erneut gezeigt wird. `citizenEventsFired` wird erst in
// applyCitizenChoice gefüllt — so überlebt ein Refresh mitten im
// Dialog: die View kann den Event wieder zeigen, solange der
// Spieler*in noch nicht entschieden hat.
game.pendingCitizenEventId = ev.id;
return ev;
}
return null;
}
/**
* applyCitizenChoice(game, eventId, choiceIndex, options = { resolveMeta })
* Rückgabe: { ok, events, unlockedAchievements, choiceTitle, eventMeta }
*/
function applyCitizenChoice(game, eventId, choiceIndex, options) {
options = options || {};
const ev = CITIZEN_EVENTS.find(e => e.id === eventId);
if (!ev) return { ok: false, reason: 'unknown-citizen-event' };
const choice = ev.choices[choiceIndex];
if (!choice) return { ok: false, reason: 'invalid-choice' };
game._beginEvents();
game._beginAchievements();
const ctx = {
resolveMeta: options.resolveMeta,
pushEvent: (icon, text, type, infoKey) => pushEvent(game, icon, text, type, infoKey),
};
choice.apply(game, ctx);
game.actionLog.push({ tick: game.tick, action:'citizen', id: ev.id, choice: choiceIndex });
recalcEffects(game);
pushEvent(game, ev.char, ev.name + ': „' + choice.title + '"', 'neutral');
// Erst nach der Entscheidung als endgültig erledigt markieren; so kann
// ein Refresh mitten im Dialog den Event erneut anzeigen.
game.citizenEventsFired.add(ev.id);
game.pendingCitizenEventId = null;
return {
ok: true,
choiceTitle: choice.title,
eventMeta: { id: ev.id, char: ev.char, name: ev.name },
events: game._drainEvents(),
unlockedAchievements: game._drainAchievements(),
};
}
function getCitizenEvent(eventId) {
return CITIZEN_EVENTS.find(e => e.id === eventId) || null;
}
/* ============================================================
NARRATIVE EVENTS (tickweise)
============================================================ */
function tickNarrativeEvents(game) {
if (game.tick === 4 && !eventFiredOnce(game, 'model-hint'))
pushEvent(game, '📚', 'Diese Insel steht stellvertretend für das gesamte Weltklima.', 'neutral', 'wedge');
if (game.tick === 10 && !eventFiredOnce(game, 'warning-1'))
pushEvent(game, '⚠️', 'Wissenschaftler*innen warnen: CO₂ steigt weiter!', 'bad', 'co2');
if (game.tick === 30 && game.currentTemp > 15.5 && !eventFiredOnce(game, 'warning-temp1'))
pushEvent(game, '🌡️', 'Die Temperatur ist um 0,5 °C gestiegen.', 'bad', 'temperature');
if (game.tick === 50 && game.currentTemp > 16 && !eventFiredOnce(game, 'warning-temp2'))
pushEvent(game, '🌡️', '+1 °C: Hitzewellen werden häufiger.', 'bad', 'temperature');
if (game.tick === 80 && game.currentTemp > 17 && !eventFiredOnce(game, 'warning-temp3'))
pushEvent(game, '🔥', '+2 °C: Pariser Klimaziel überschritten!', 'bad', 'temperature');
if (game.floodedPct > 8 && game.floodedPct < 20 && !eventFiredOnce(game, 'flood-1'))
pushEvent(game, '🌊', 'Erste Häuser an der Küste sind betroffen.', 'bad', 'sealevel');
if (game.floodedPct > 40 && !eventFiredOnce(game, 'flood-2'))
pushEvent(game, '🌊', 'Massive Überflutung — viele verlieren ihr Zuhause!', 'bad', 'sealevel');
if (game.seaLevelCm > 20 && !eventFiredOnce(game, 'vegetation-dying'))
pushEvent(game, '🌿', 'Mangroven und Küstenwälder sterben — Salzwasser versalzt die Böden.', 'bad', 'vegetation');
if (game.seaLevelCm > 35 && !eventFiredOnce(game, 'drinking-water'))
pushEvent(game, '💧', 'Die Brunnen liefern immer mehr salziges Wasser.', 'bad', 'drinking_water');
if (game.currentTemp > 16.8 && !eventFiredOnce(game, 'glacier-melt'))
pushEvent(game, '🏔', 'Der Gletscher am Vulkan schrumpft merklich.', 'bad', 'glacier');
if (game.tick === 60 && game.budget > 500 && !eventFiredOnce(game, 'praise-1'))
pushEvent(game, '👏', 'Die Bevölkerung lobt deine kluge Haushaltsführung.', 'good');
if (game.tick === 100 && game.co2Reduction > 3 && !eventFiredOnce(game, 'praise-co2'))
pushEvent(game, '🌿', 'Deine Klimaschutzmaßnahmen zeigen Wirkung!', 'good');
if (game.tick === 120 && game.currentTemp < 16 && !eventFiredOnce(game, 'praise-temp'))
pushEvent(game, '🏆', 'Internationale Anerkennung für deine Klimapolitik!', 'good');
const cs = game.ownedMeasures['cloud-seed'];
if (cs && cs.count > 0 && cs.instances.length > 0 && !eventFiredOnce(game, 'cloud-seed-verdict')) {
const builtAt = cs.instances[0].builtAt;
if (game.tick - builtAt >= 30) {
pushEvent(game, '☁️',
'Wolken-Impfung nach 30 Jahren: reine Scheinlösung. Wirkung minimal, Geld besser in echte Klimaschutzmaßnahmen investieren.',
'bad', 'cloud_seeding');
}
}
const ap = game.ownedMeasures['airport'];
if (ap && ap.count > 0 && ap.instances.length > 0 && !eventFiredOnce(game, 'airport-verdict')) {
const builtAt = ap.instances[0].builtAt;
if (game.tick - builtAt >= 20) {
pushEvent(game, '✈️',
'Tourismus-Bilanz: Einnahmen stehen gegen zusätzlichen CO₂-Ausstoß. Die Temperatur-Kurve wäre ohne Flughafen niedriger.',
'bad', 'aviation');
}
}
const coal = game.ownedMeasures['coal'];
if (coal && coal.count > 0 && coal.instances.length > 0 && !eventFiredOnce(game, 'coal-verdict')) {
const builtAt = coal.instances[0].builtAt;
if (game.tick - builtAt >= 16) {
pushEvent(game, '🏭',
'Kohle-Bilanz: dein Kraftwerk hat jetzt '+(coal.count*Math.abs(-0.38).toFixed(2))+' ppm/Jahr zusätzliches CO₂ in die Luft gepumpt. Abschalten und auf Wind/Solar umsteigen?',
'bad', 'coal_power');
}
}
}
/* ============================================================
TICK — Ein Jahr simulieren
============================================================ */
/**
* tick(game) — Rückgabe:
* {
* newEvents: [...],
* unlockedAchievements: [...],
* citizenEvent: citizenEventObject | null,
* endReason: 'won' | 'pleite' | 'ueberflutet' | null,
* timelineSample: { tick, co2, temp, budget, sea, power, demand }
* }
*/
function tick(game) {
if (game.levelCompleted) return { newEvents: [], unlockedAchievements: [], citizenEvent: null, endReason: null };
game._beginEvents();
game._beginAchievements();
recalcEffects(game);
const popRatio = game.population / 10000;
game.budget += Math.round(game.diff.incomePer10k * popRatio);
if (game.tourismMode) {
if (game.seaLevelCm < 40) {
game.budget += Math.round(35 * popRatio);
game.co2Ppm += 0.6;
} else {
game.population -= Math.round(60 * popRatio);
if (!eventFiredOnce(game, 'tourism-collapse')) {
pushEvent(game, '🏖', 'Der Strand ist vom Meer verschluckt — der Tourismus bricht zusammen!', 'bad', 'sealevel');
}
}
}
game.budget -= game.upkeepTotal;
const emissions = getEmissionsThisYear(game);
const reduction = game.co2Reduction;
game.co2Ppm = Math.max(CO2_FLOOR, game.co2Ppm + emissions - reduction);
computeClimate(game);
// Strom-Bilanz / Blackout
const powerDemand = getPowerDemand(game);
if (powerDemand > game.powerOutput) {
game.blackoutStreak++;
if (!eventFiredOnce(game, 'blackout-first')) {
pushEvent(game, '⚡', 'Zu wenig Strom! Baue schnell ein Kraftwerk, sonst wird es bald kalt.', 'bad', 'blackout');
}
if (game.blackoutStreak > game.diff.blackoutGrace) {
game.treesChoppedForHeat += game.diff.treesPerBlackout;
game.co2Ppm += game.diff.co2BlackoutPerYear;
const forest = game.ownedMeasures.forest;
if (forest && forest.count > 0) {
const kill = Math.min(forest.count, game.diff.treesPerBlackout);
forest.count -= kill;
for (let k=0; k<kill; k++) forest.instances.pop();
if (forest.count === 0) delete game.ownedMeasures.forest;
}
}
const thr = game.diff.blackoutGrace + 3;
if (game.blackoutStreak >= thr) {
const drift = Math.round(25 * popRatio * game.diff.popLossMul * (game.blackoutStreak - thr + 1));
game.population -= drift;
}
} else {
if (game.blackoutStreak > game.diff.blackoutGrace && !eventFiredOnce(game, 'blackout-recovered')) {
pushEvent(game, '💡', 'Der Strom ist wieder da. Die gefällten Bäume kommen aber nicht zurück.', 'neutral');
}
game.blackoutStreak = 0;
}
// Überflutungs-Schäden — Multiplikator gesenkt (5 → 3), weil die Flut-Kurve
// jetzt stetig ist und mittlere Läufe höhere %-Werte zeigen als früher.
if (game.floodedPct > 0) {
game.population -= Math.round(game.floodedPct * 3 * game.diff.popLossMul);
}
if (game.seaLevelCm > 25) {
const sev = Math.min(1, (game.seaLevelCm - 25) / 40);
game.budget -= Math.round(sev * 12 * game.diff.climateDamageMul);
if (Math.random() < sev * game.diff.popLossMul) {
game.population -= Math.round(sev * 15 * game.diff.popLossMul);
}
}
if (game.population < 0) game.population = 0;
tickNarrativeEvents(game);
const citizenEv = maybeCitizenEvent(game);
computePopulationGrowth(game);
const timelineSample = {
tick: game.tick,
co2: game.co2Ppm,
temp: game.currentTemp,
budget: game.budget,
sea: game.seaLevelCm,
power: game.powerOutput,
demand: getPowerDemand(game),
};
game.timeline.push(timelineSample);
checkRenewable100(game);
if (game.currentTemp < 16.5 && game.tick > 30 && !game.achievements.has('under_1_5')) tryUnlockAchievement(game, 'under_1_5');
game.tick++;
// End-Bedingungen
let endReason = null;
const budgetLoss = (game.levelConfig && typeof game.levelConfig.budgetLoss === 'number') ? game.levelConfig.budgetLoss : -500;
// Hitze-Katastrophe: > 18,5 °C = Klima-System ausser Kontrolle → Niederlage,
// unabhängig vom restlichen Spielstand. „Überlebt, aber Klima zerstört" ist
// didaktisch kein Sieg.
if (game.currentTemp >= 18.5) {
endReason = 'hitze';
game.levelCompleted = true;
game.levelWon = false;
} else if (game.tick >= game.maxTick) {
endReason = 'won';
game.levelCompleted = true;
game.levelWon = true;
tryUnlockAchievement(game, 'survivor');
if (game.budget > 100) tryUnlockAchievement(game, 'frugal');
} else if (game.budget < budgetLoss) {
endReason = 'pleite';
game.levelCompleted = true;
game.levelWon = false;
} else if (game.floodedPct > 60) {
endReason = 'ueberflutet';
game.levelCompleted = true;
game.levelWon = false;
}
return {
newEvents: game._drainEvents(),
unlockedAchievements: game._drainAchievements(),
citizenEvent: citizenEv,
endReason,
timelineSample,
};
}
/* ============================================================
SPIEL ERZEUGEN / ZURÜCKSETZEN
============================================================ */
function createGame(levelId, opts) {
opts = opts || {};
const diff = DIFFICULTY[levelId] || DIFFICULTY[1];
const game = {
// Simulations-State
tick: 0,
maxTick: opts.maxTick || 150,
startYear: opts.startYear || 2025,
difficulty: levelId,
diff,
levelConfig: opts.levelConfig || null,
budget: diff.startBudget,
population: diff.startPopulation,
co2Ppm: 425,
currentTemp: 15.5,
targetTemp: 15.5,
seaLevelCm: 0, floodedPct: 0,
seaFastCm: 0, committedSeaCm: 0,
ownedMeasures: {},
co2Reduction: 0, protection: 0, upkeepTotal: 0, powerOutput: 0, renewablePower: 0,
// Stapel + Historie
timeline: [],
actionLog: [],
events: [],
firedEvents: new Set(),
citizenEventsFired: new Set(),
achievements: new Set(),
// Flags
researchDiscount: 1.0,
tourismMode: false, hasMountainShield: false, hasSandBeach: false,
negBalanceWarningShown: false,
blackoutStreak: 0, treesChoppedForHeat: 0,
pendingCitizenEventId: null,
levelCompleted: false, levelWon: false,
startTime: Date.now(),
lastProgressTick: -1,
// Event-/Achievement-Puffer, pro Aufruf geleert (internal)
_pendingEvents: [],
_pendingAchievements: [],
_beginEvents: function() { this._pendingEvents = []; },
_drainEvents: function() { const out = this._pendingEvents; this._pendingEvents = []; return out; },
_beginAchievements: function() { this._pendingAchievements = []; },
_drainAchievements: function() { const out = this._pendingAchievements; this._pendingAchievements = []; return out; },
};
// Klima konsistent initialisieren
game.currentTemp = computeTemperatureFromCO2(game.co2Ppm);
game.targetTemp = game.currentTemp;
recalcEffects(game);
return game;
}
/* ============================================================
SERIALIZE / DESERIALIZE
============================================================ */
function serialize(game) {
return {
v: 4,
tick: game.tick,
maxTick: game.maxTick,
startYear: game.startYear,
difficulty: game.difficulty,
levelConfig: game.levelConfig,
budget: game.budget, population: game.population,
co2Ppm: game.co2Ppm, currentTemp: game.currentTemp, targetTemp: game.targetTemp,
seaLevelCm: game.seaLevelCm, floodedPct: game.floodedPct,
seaFastCm: game.seaFastCm, committedSeaCm: game.committedSeaCm,
ownedMeasures: game.ownedMeasures,
timeline: game.timeline, events: game.events,
firedEvents: Array.from(game.firedEvents),
citizenEventsFired: Array.from(game.citizenEventsFired),
achievements: Array.from(game.achievements),
pendingCitizenEventId: game.pendingCitizenEventId,
researchDiscount: game.researchDiscount,
tourismMode: game.tourismMode, hasMountainShield: game.hasMountainShield,
hasSandBeach: game.hasSandBeach,
negBalanceWarningShown: game.negBalanceWarningShown,
blackoutStreak: game.blackoutStreak,
treesChoppedForHeat: game.treesChoppedForHeat,
lastProgressTick: game.lastProgressTick,
levelCompleted: game.levelCompleted, levelWon: game.levelWon,
savedAt: Date.now(),
};
}
function deserialize(data) {
if (!data || typeof data !== 'object') return null;
const game = createGame(data.difficulty || 1, {
maxTick: data.maxTick,
startYear: data.startYear,
levelConfig: data.levelConfig,
});
game.tick = data.tick | 0;
game.budget = data.budget || 0;
game.population = data.population || 0;
game.co2Ppm = data.co2Ppm || 425;
game.currentTemp = data.currentTemp || 15.5;
game.targetTemp = data.targetTemp || game.currentTemp;
game.seaLevelCm = data.seaLevelCm || 0;
game.seaFastCm = data.seaFastCm || 0;
game.committedSeaCm = data.committedSeaCm || 0;
game.floodedPct = data.floodedPct || 0;
game.ownedMeasures = data.ownedMeasures || {};
game.timeline = Array.isArray(data.timeline) ? data.timeline : [];
game.events = Array.isArray(data.events) ? data.events : [];
game.firedEvents = new Set(data.firedEvents || []);
game.citizenEventsFired = new Set(data.citizenEventsFired || []);
game.achievements = new Set(data.achievements || []);
game.pendingCitizenEventId = data.pendingCitizenEventId || null;
game.researchDiscount = data.researchDiscount != null ? data.researchDiscount : 1.0;
game.tourismMode = !!data.tourismMode;
game.hasMountainShield = !!data.hasMountainShield;
game.hasSandBeach = !!data.hasSandBeach;
game.negBalanceWarningShown = !!data.negBalanceWarningShown;
game.blackoutStreak = data.blackoutStreak || 0;
game.treesChoppedForHeat= data.treesChoppedForHeat || 0;
game.lastProgressTick = data.lastProgressTick != null ? data.lastProgressTick : -1;
game.levelCompleted = !!data.levelCompleted;
game.levelWon = !!data.levelWon;
recalcEffects(game);
return game;
}
/* ============================================================
PUBLIC API
============================================================ */
window.KlimaEngine = {
// Konstanten
MEASURES,
DIFFICULTY,
ACHIEVEMENTS,
CITIZEN_EVENTS,
FIRST_BUY_HINTS,
CO2_FLOOR,
TEMP_FLOOR,
// Lebenszyklus
createGame,
serialize,
deserialize,
// Ableitung
recalcEffects,
computeTemperatureFromCO2,
computeClimate,
getEmissionsThisYear,
getPowerDemand,
getIncomeThisYear,
getYearlyBalance,
computePopulationGrowth,
// Aktionen
buyMeasure,
demolishMeasure,
// Tick
tick,
// Bürger-Events
maybeCitizenEvent,
applyCitizenChoice,
getCitizenEvent,
// Achievements
checkRenewable100,
tryUnlockAchievement,
// Helpers
findMeasure,
pushEvent,
};
})();