/** * 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= 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, }; })();