Klima: Engine extrahiert + 2D refactored + Drama-Track + Bugfixes
- engine.js als headless Single Source of Truth (KlimaEngine) - game-2d.html nutzt engine (-515 Zeilen Duplikation) - rising-pressure.mp3 als Drama-Slot, Auto-Switch bei kritischem State - state.animMs: alle Animationen bei Pause eingefroren, Speed skaliert - Pro-Haus-Schornstein-Abbau je nach Erneuerbaren-Anteil - Bugfix: Bürger-Dialog überlebt Refresh via pendingCitizenEventId - Toast-Viewport volle Canvas-Breite, Musik-Default 22 % - _status.md Konvention eingeführt, Inbox-Nachrichten erhalten
This commit is contained in:
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const effectiveRise = Math.max(0, game.seaLevelCm - game.protection);
|
||||
game.floodedPct = effectiveRise > 35 ? Math.min(100, (effectiveRise - 35) * 0.9) : 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
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 < 5 ? 1 : game.floodedPct < 15 ? 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 > 5 && game.floodedPct < 10 && !eventFiredOnce(game, 'flood-1'))
|
||||
pushEvent(game, '🌊', 'Erste Häuser an der Küste sind betroffen.', 'bad', 'sealevel');
|
||||
if (game.floodedPct > 20 && !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
|
||||
if (game.floodedPct > 0) {
|
||||
game.population -= Math.round(game.floodedPct * 5 * 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;
|
||||
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 > 50) {
|
||||
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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user