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,14 @@
|
||||
# Klimawächter 2D — Version 2
|
||||
|
||||
Dieses Verzeichnis enthält die Neuentwicklung des Klimawächter-2D-Moduls
|
||||
basierend auf dem GeoGraSim Design-System.
|
||||
|
||||
- V1 (alt): `App/game.html` — bleibt als Fallback bestehen
|
||||
- V2 (neu): `App/sims/klima/game-2d.html` — diese Version
|
||||
|
||||
## Referenzdateien
|
||||
|
||||
- Design-System: `App/assets/css/design-system.css`
|
||||
- Template: `App/sims/template.html`
|
||||
- Interface-Spec: `App/docs/module-interface.md`
|
||||
- PHP-Wrapper: `App/pages/klima-2d.php` (neu anzulegen)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Klimawächter Audio-Modul
|
||||
* ------------------------------------------------------------------
|
||||
* Kleines SFX-System auf Basis von <audio>-Elementen mit Pooling,
|
||||
* damit dasselbe SFX mehrfach kurz hintereinander abgespielt werden
|
||||
* kann (z. B. rasche Kauf-Sounds). Persistiert Volume + Mute in
|
||||
* localStorage, reagiert auf Speed 0 (Pause) indem es nichts abspielt.
|
||||
*
|
||||
* Nutzung aus dem Spiel:
|
||||
* kwAudio.play('ui-click');
|
||||
* kwAudio.play('build-wind');
|
||||
* kwAudio.setVolume(0.5); // 0..1
|
||||
* kwAudio.toggleMute();
|
||||
*
|
||||
* Alle Sound-Dateien liegen in ./assets/sounds/<name>.mp3.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const BASE = 'assets/sounds/';
|
||||
const POOL_SIZE = 3; // parallele Instanzen pro Sound (überlappen)
|
||||
const LS_VOL = 'kw-sfx-volume';
|
||||
const LS_MUTE = 'kw-sfx-muted';
|
||||
|
||||
// Mapping Welt-Event/Key → Sound-Datei. Die Keys werden vom Spiel
|
||||
// direkt als `play(key)`-Argument übergeben.
|
||||
const SOUNDS = {
|
||||
// UI + Meta
|
||||
'ui-click': 'ui-click.mp3',
|
||||
'ui-error': 'ui-error.mp3',
|
||||
'ui-confirm': 'ui-confirm.mp3',
|
||||
'achievement': 'achievement.mp3',
|
||||
'warning': 'warning.mp3',
|
||||
'praise': 'praise.mp3',
|
||||
'level-won': 'level-won.mp3',
|
||||
'level-lost': 'level-lost.mp3',
|
||||
'game-start': 'game-start.mp3',
|
||||
'demolish': 'demolish.mp3',
|
||||
// Bau-Sounds
|
||||
'build-generic': 'build-generic.mp3',
|
||||
'build-forest': 'build-forest.mp3',
|
||||
'build-solar': 'build-solar.mp3',
|
||||
'build-wind': 'build-wind.mp3',
|
||||
'build-green-roof': 'build-green-roof.mp3',
|
||||
'build-bikes': 'build-bikes.mp3',
|
||||
'build-mangrove': 'build-mangrove.mp3',
|
||||
'build-sand-fill': 'build-sand-fill.mp3',
|
||||
'build-dike': 'build-dike.mp3',
|
||||
'build-sea-wall': 'build-sea-wall.mp3',
|
||||
'build-coal': 'build-coal.mp3',
|
||||
'build-airport': 'build-airport.mp3',
|
||||
'build-cloud-seed': 'build-cloud-seed.mp3',
|
||||
// Ereignis-Sounds
|
||||
'event-temperature': 'event-temperature.mp3',
|
||||
'event-flood': 'event-flood.mp3',
|
||||
'event-vegetation': 'event-vegetation.mp3',
|
||||
'event-water': 'event-water.mp3',
|
||||
'event-glacier': 'event-glacier.mp3',
|
||||
'event-blackout': 'event-blackout.mp3',
|
||||
'event-power-back': 'event-power-back.mp3',
|
||||
'event-tourism': 'event-tourism.mp3',
|
||||
'event-co2': 'event-co2.mp3',
|
||||
// Dramatik
|
||||
'drama-paris-missed': 'drama-paris-missed.mp3',
|
||||
'drama-disaster': 'drama-disaster.mp3',
|
||||
'drama-game-over': 'drama-game-over.mp3',
|
||||
};
|
||||
|
||||
// Mapping buy(measureId) → sound-key
|
||||
const BUILD_MAP = {
|
||||
'forest': 'build-forest',
|
||||
'solar': 'build-solar',
|
||||
'wind': 'build-wind',
|
||||
'green-roof': 'build-green-roof',
|
||||
'bikes': 'build-bikes',
|
||||
'mangrove': 'build-mangrove',
|
||||
'sand-fill': 'build-sand-fill',
|
||||
'dike': 'build-dike',
|
||||
'sea-wall': 'build-sea-wall',
|
||||
'coal': 'build-coal',
|
||||
'airport': 'build-airport',
|
||||
'cloud-seed': 'build-cloud-seed',
|
||||
};
|
||||
|
||||
// Sound-Pools (pro Key ein Array von <audio>-Elementen im Round-Robin)
|
||||
const _pools = {};
|
||||
const _poolIdx = {};
|
||||
|
||||
let _volume = parseFloat(localStorage.getItem(LS_VOL));
|
||||
if (isNaN(_volume)) _volume = 0.6;
|
||||
let _muted = localStorage.getItem(LS_MUTE) === '1';
|
||||
|
||||
function _getPool(key) {
|
||||
if (_pools[key]) return _pools[key];
|
||||
const file = SOUNDS[key];
|
||||
if (!file) return null;
|
||||
const arr = [];
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const a = new Audio(BASE + file);
|
||||
a.preload = 'auto';
|
||||
a.volume = _volume;
|
||||
arr.push(a);
|
||||
}
|
||||
_pools[key] = arr;
|
||||
_poolIdx[key] = 0;
|
||||
return arr;
|
||||
}
|
||||
|
||||
function play(key, opts) {
|
||||
if (_muted) return;
|
||||
const pool = _getPool(key);
|
||||
if (!pool) { console.warn('[kwAudio] unbekannt:', key); return; }
|
||||
const idx = _poolIdx[key];
|
||||
_poolIdx[key] = (idx + 1) % pool.length;
|
||||
const a = pool[idx];
|
||||
try {
|
||||
a.currentTime = 0;
|
||||
a.volume = (opts && opts.volume != null) ? opts.volume : _volume;
|
||||
const p = a.play();
|
||||
if (p && p.catch) p.catch(() => { /* Autoplay-Policy o.ä. — ignorieren */ });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function playForBuy(measureId) {
|
||||
const key = BUILD_MAP[measureId] || 'build-generic';
|
||||
play(key);
|
||||
}
|
||||
|
||||
function setVolume(v) {
|
||||
_volume = Math.max(0, Math.min(1, v));
|
||||
localStorage.setItem(LS_VOL, String(_volume));
|
||||
for (const k in _pools) {
|
||||
for (const a of _pools[k]) a.volume = _volume;
|
||||
}
|
||||
}
|
||||
|
||||
function setMuted(m) {
|
||||
_muted = !!m;
|
||||
localStorage.setItem(LS_MUTE, _muted ? '1' : '0');
|
||||
if (_muted) stopAll();
|
||||
}
|
||||
|
||||
function toggleMute() { setMuted(!_muted); return _muted; }
|
||||
function isMuted() { return _muted; }
|
||||
function getVolume() { return _volume; }
|
||||
|
||||
function stopAll() {
|
||||
for (const k in _pools) {
|
||||
for (const a of _pools[k]) { try { a.pause(); a.currentTime = 0; } catch (e) {} }
|
||||
}
|
||||
}
|
||||
|
||||
// Exportieren
|
||||
window.kwAudio = {
|
||||
play,
|
||||
playForBuy,
|
||||
setVolume,
|
||||
getVolume,
|
||||
setMuted,
|
||||
isMuted,
|
||||
toggleMute,
|
||||
stopAll,
|
||||
SOUNDS,
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Klimawächter SFX-Generator für ElevenLabs Sound Effects API.
|
||||
|
||||
Liest App/.env.local (ELEVENLABS_API_KEY) + sounds-list.json und lädt für
|
||||
jeden Eintrag einen MP3 von ElevenLabs in ../assets/sounds/. Existierende
|
||||
Dateien werden übersprungen (für inkrementelles Laufenlassen).
|
||||
|
||||
Aufruf:
|
||||
cd App/sims/klima/scripts
|
||||
python generate-sounds.py # alle fehlenden Sounds generieren
|
||||
python generate-sounds.py --force # ALLE neu generieren (überschreibt)
|
||||
python generate-sounds.py ui-click # nur diesen einen Sound
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
API_URL = "https://api.elevenlabs.io/v1/sound-generation"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SOUNDS_LIST = SCRIPT_DIR / "sounds-list.json"
|
||||
OUT_DIR = SCRIPT_DIR.parent / "assets" / "sounds"
|
||||
APP_ROOT = SCRIPT_DIR.parent.parent.parent # App/
|
||||
ENV_FILE = APP_ROOT / ".env.local"
|
||||
|
||||
|
||||
def load_env(file_path: Path) -> dict:
|
||||
env = {}
|
||||
if not file_path.exists():
|
||||
return env
|
||||
for line in file_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
env[key.strip()] = val.strip()
|
||||
return env
|
||||
|
||||
|
||||
def generate_sound(api_key: str, prompt: str, duration: float, influence: float) -> bytes:
|
||||
body = json.dumps({
|
||||
"text": prompt,
|
||||
"duration_seconds": duration,
|
||||
"prompt_influence": influence,
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
API_URL,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"xi-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/mpeg",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = load_env(ENV_FILE)
|
||||
api_key = env.get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY")
|
||||
if not api_key:
|
||||
print(f"FEHLER: ELEVENLABS_API_KEY nicht in {ENV_FILE} oder Umgebung gefunden.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
force = "--force" in sys.argv
|
||||
only_file = None
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith("--"):
|
||||
continue
|
||||
only_file = arg + ".mp3" if not arg.endswith(".mp3") else arg
|
||||
break
|
||||
|
||||
data = json.loads(SOUNDS_LIST.read_text(encoding="utf-8"))
|
||||
sounds = data["sounds"]
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total = len(sounds)
|
||||
generated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for i, s in enumerate(sounds, 1):
|
||||
name = s["file"]
|
||||
out = OUT_DIR / name
|
||||
if only_file and name != only_file:
|
||||
continue
|
||||
if out.exists() and not force:
|
||||
skipped += 1
|
||||
print(f"[{i:2}/{total}] SKIP {name:28} (schon da, --force überschreibt)")
|
||||
continue
|
||||
|
||||
prompt = s["prompt"]
|
||||
dur = float(s.get("duration", 2.0))
|
||||
infl = float(s.get("influence", 0.5))
|
||||
print(f"[{i:2}/{total}] GEN {name:28} dur={dur}s → {prompt[:60]}…")
|
||||
try:
|
||||
audio = generate_sound(api_key, prompt, dur, infl)
|
||||
out.write_bytes(audio)
|
||||
generated += 1
|
||||
# ElevenLabs verlangt keine Rate-Limit-Pause, aber 0,5s Pause
|
||||
# reduziert Last und macht Fehler besser lesbar.
|
||||
time.sleep(0.5)
|
||||
except urllib.error.HTTPError as e:
|
||||
msg = e.read().decode("utf-8", errors="ignore")[:200]
|
||||
print(f" HTTP {e.code}: {msg}", file=sys.stderr)
|
||||
errors += 1
|
||||
except Exception as e:
|
||||
print(f" Fehler: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f"Ergebnis: {generated} neu, {skipped} übersprungen, {errors} Fehler")
|
||||
print(f"Zielordner: {OUT_DIR}")
|
||||
return 0 if errors == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Klimawächter · Sound-Preview</title>
|
||||
<link rel="stylesheet" href="../../../assets/fonts/inter.css">
|
||||
<link rel="stylesheet" href="../../../assets/css/design-system.css">
|
||||
<style>
|
||||
body { overflow: auto; height: auto; min-height: 100vh; background: var(--ggs-bg); padding: 32px; }
|
||||
h1 { color: var(--ggs-fjord-dark); font-size: 28px; margin-bottom: 8px; }
|
||||
.lead { color: var(--ggs-text-muted); margin-bottom: 24px; max-width: 700px; line-height: 1.5; }
|
||||
.group { margin-bottom: 28px; }
|
||||
.group h2 {
|
||||
color: var(--ggs-fjord-dark); font-size: 16px; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; margin-bottom: 12px; border-bottom: 2px solid var(--ggs-border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--ggs-white); border: 1px solid var(--ggs-border);
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.card h3 { font-size: 13px; font-weight: 800; color: var(--ggs-fjord-dark); margin: 0; }
|
||||
.card .meta { font-size: 11px; color: var(--ggs-text-muted); }
|
||||
.card .prompt { font-size: 11px; color: var(--ggs-text); line-height: 1.4; font-style: italic; }
|
||||
.card audio { width: 100%; margin-top: 4px; }
|
||||
.card.playing { border-color: var(--ggs-moss); box-shadow: 0 2px 8px rgba(90,138,94,0.3); }
|
||||
.toolbar {
|
||||
position: sticky; top: 0; background: var(--ggs-bg);
|
||||
padding: 10px 0; border-bottom: 1px solid var(--ggs-border);
|
||||
margin-bottom: 24px; z-index: 10;
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.toolbar label { font-size: 13px; color: var(--ggs-text); display: flex; align-items: center; gap: 6px; }
|
||||
.toolbar input[type=range] { width: 140px; }
|
||||
.toolbar .count { font-size: 12px; color: var(--ggs-text-muted); margin-left: auto; }
|
||||
.btn {
|
||||
padding: 6px 14px; border: 1.5px solid var(--ggs-fjord); background: var(--ggs-white);
|
||||
color: var(--ggs-fjord-dark); border-radius: 6px; font-weight: 600; cursor: pointer;
|
||||
font-family: inherit; font-size: 13px;
|
||||
}
|
||||
.btn:hover { background: var(--ggs-fjord-light); }
|
||||
.btn.primary { background: var(--ggs-fjord); color: #fff; }
|
||||
.btn.primary:hover { background: var(--ggs-fjord-dark); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🔊 Klimawächter — Sound-Preview</h1>
|
||||
<p class="lead">
|
||||
Alle 32 SFX aus <code>assets/sounds/</code>. Klick auf einen Play-Button, um zu hören.
|
||||
Falls ein Sound nicht passt: Dateinamen merken, ich kann den Prompt feintunen und
|
||||
nur diesen einen neu generieren (<code>python generate-sounds.py <name> --force</code>).
|
||||
</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="btn-play-all">▶ Alle nacheinander</button>
|
||||
<button class="btn" id="btn-stop">⏹ Stop</button>
|
||||
<label>Lautstärke <input type="range" id="vol" min="0" max="1" step="0.05" value="0.7"></label>
|
||||
<span class="count" id="count">Lädt …</span>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
const GROUPS = {
|
||||
'UI + Meta': ['ui-click', 'ui-error', 'ui-confirm', 'achievement', 'warning', 'praise', 'level-won', 'level-lost', 'game-start', 'demolish'],
|
||||
'Bau (Klima-positiv)': ['build-forest', 'build-solar', 'build-wind', 'build-green-roof', 'build-bikes', 'build-mangrove'],
|
||||
'Bau (Küstenschutz)': ['build-sand-fill', 'build-dike', 'build-sea-wall'],
|
||||
'Bau (Klima-negativ / Schein)': ['build-coal', 'build-airport', 'build-cloud-seed', 'build-generic'],
|
||||
'Ereignisse': ['event-temperature', 'event-flood', 'event-vegetation', 'event-water', 'event-glacier', 'event-blackout', 'event-power-back', 'event-tourism', 'event-co2'],
|
||||
'Dramatik (wenn es kippt)': ['drama-paris-missed', 'drama-disaster', 'drama-game-over'],
|
||||
};
|
||||
|
||||
let DATA = { sounds: [] };
|
||||
let currentAudio = null;
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('sounds-list.json');
|
||||
DATA = await res.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function findMeta(file) {
|
||||
return DATA.sounds.find(s => s.file === file + '.mp3');
|
||||
}
|
||||
|
||||
function render() {
|
||||
const root = document.getElementById('root');
|
||||
let html = '';
|
||||
let total = 0;
|
||||
for (const [groupName, files] of Object.entries(GROUPS)) {
|
||||
html += '<div class="group"><h2>' + groupName + ' (' + files.length + ')</h2><div class="grid">';
|
||||
for (const file of files) {
|
||||
const meta = findMeta(file);
|
||||
const prompt = meta ? meta.prompt : '';
|
||||
const dur = meta ? meta.duration : '?';
|
||||
html += `
|
||||
<div class="card" data-file="${file}">
|
||||
<h3>${file}.mp3</h3>
|
||||
<div class="meta">Dauer: ${dur} s</div>
|
||||
<div class="prompt">${prompt}</div>
|
||||
<audio controls preload="none" src="../assets/sounds/${file}.mp3"></audio>
|
||||
</div>`;
|
||||
total++;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
root.innerHTML = html;
|
||||
document.getElementById('count').textContent = total + ' Sounds';
|
||||
|
||||
// Volumen global setzen
|
||||
const vol = document.getElementById('vol');
|
||||
document.querySelectorAll('audio').forEach(a => {
|
||||
a.volume = parseFloat(vol.value);
|
||||
a.addEventListener('play', () => {
|
||||
document.querySelectorAll('audio').forEach(o => { if (o !== a) { o.pause(); o.currentTime = 0; } });
|
||||
a.closest('.card').classList.add('playing');
|
||||
currentAudio = a;
|
||||
});
|
||||
a.addEventListener('pause', () => a.closest('.card').classList.remove('playing'));
|
||||
a.addEventListener('ended', () => a.closest('.card').classList.remove('playing'));
|
||||
});
|
||||
vol.addEventListener('input', () => {
|
||||
document.querySelectorAll('audio').forEach(a => a.volume = parseFloat(vol.value));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-play-all').addEventListener('click', () => {
|
||||
const all = Array.from(document.querySelectorAll('audio'));
|
||||
let i = 0;
|
||||
const next = () => {
|
||||
if (i >= all.length) return;
|
||||
const a = all[i++];
|
||||
a.currentTime = 0;
|
||||
a.play();
|
||||
a.addEventListener('ended', next, { once: true });
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
document.getElementById('btn-stop').addEventListener('click', () => {
|
||||
document.querySelectorAll('audio').forEach(a => { a.pause(); a.currentTime = 0; });
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"_comment": "SFX-Prompts für ElevenLabs Sound Effects API. Jeder Eintrag wird als MP3 in ../assets/sounds/ generiert. 'duration' = Länge in Sekunden (ElevenLabs max 22 s). 'influence' = prompt_influence 0..1 (höher = näher am Prompt, niedriger = mehr Variation).",
|
||||
"sounds": [
|
||||
{ "file": "ui-click.mp3", "duration": 0.5, "influence": 0.5, "prompt": "Soft UI button click, short tock, light plastic feel, single hit, dry" },
|
||||
{ "file": "ui-error.mp3", "duration": 0.6, "influence": 0.5, "prompt": "Gentle error beep, soft dismissive tone, descending two-note, not harsh" },
|
||||
{ "file": "ui-confirm.mp3", "duration": 0.5, "influence": 0.5, "prompt": "Friendly confirmation chime, short positive ding, warm bell" },
|
||||
{ "file": "achievement.mp3", "duration": 2.0, "influence": 0.5, "prompt": "Short cheerful achievement fanfare, warm bells and uplifting chime, celebratory but brief" },
|
||||
{ "file": "warning.mp3", "duration": 1.2, "influence": 0.5, "prompt": "Gentle warning alert, soft low-frequency pulsing hum, cautionary not alarming" },
|
||||
{ "file": "praise.mp3", "duration": 1.2, "influence": 0.5, "prompt": "Happy positive chime, warm glockenspiel ascending, optimistic mood" },
|
||||
{ "file": "level-won.mp3", "duration": 3.0, "influence": 0.6, "prompt": "Uplifting victory jingle with soft brass and bells, triumphant but calm, not over-the-top" },
|
||||
{ "file": "level-lost.mp3", "duration": 3.0, "influence": 0.6, "prompt": "Gentle disappointing fade-out, descending piano chords, melancholy but not tragic" },
|
||||
{ "file": "game-start.mp3", "duration": 1.5, "influence": 0.5, "prompt": "Welcoming game-start chime, warm rising tone with soft strings, inviting" },
|
||||
{ "file": "build-generic.mp3", "duration": 0.8, "influence": 0.5, "prompt": "Construction placement sound, wooden plank drop with soft thud, placement confirmation" },
|
||||
|
||||
{ "file": "build-forest.mp3", "duration": 1.5, "influence": 0.6, "prompt": "Planting a tree, earthy soil pat with gentle leaf rustle and soft breeze" },
|
||||
{ "file": "build-solar.mp3", "duration": 1.2, "influence": 0.6, "prompt": "Solar panel clicking into place, metallic click and soft electric hum starting up" },
|
||||
{ "file": "build-wind.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Large wind turbine starting up, deep whoosh with slow rhythmic blade sweep" },
|
||||
{ "file": "build-green-roof.mp3","duration": 1.5, "influence": 0.7, "prompt": "Placing grass sod on rooftop, distinct earthy thud with clear rustling grass and dirt patting, audible quick action sound" },
|
||||
{ "file": "build-bikes.mp3", "duration": 1.5, "influence": 0.6, "prompt": "Asphalt roller paving a bike path, low rumbling mechanical roll with smoothing sound" },
|
||||
{ "file": "build-mangrove.mp3", "duration": 1.8, "influence": 0.6, "prompt": "Planting mangrove roots in shallow water, soft water splash with wet leaf rustle" },
|
||||
{ "file": "build-sand-fill.mp3", "duration": 1.8, "influence": 0.6, "prompt": "Pouring sand from a truck onto beach, rushing sand cascade, dry grainy shhh" },
|
||||
{ "file": "build-dike.mp3", "duration": 1.8, "influence": 0.6, "prompt": "Shoveling earth for a levee, dirt hitting ground, muddy pat with packing thuds" },
|
||||
{ "file": "build-sea-wall.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Large concrete block being placed for sea wall, heavy dull impact with low rumble" },
|
||||
{ "file": "build-coal.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Coal power plant starting up, deep industrial rumble with distant furnace roar" },
|
||||
{ "file": "build-airport.mp3", "duration": 2.5, "influence": 0.6, "prompt": "Jet airplane taking off in the distance, rising turbine whine" },
|
||||
{ "file": "build-cloud-seed.mp3","duration": 1.8, "influence": 0.6, "prompt": "Pressurized salt water spray mist being released upward into sky, hissing aerosol" },
|
||||
|
||||
{ "file": "demolish.mp3", "duration": 1.2, "influence": 0.5, "prompt": "Demolition crumble, soft rubble falling, wooden snap with dust settling" },
|
||||
|
||||
{ "file": "event-temperature.mp3","duration": 1.5, "influence": 0.6, "prompt": "Temperature rising alert, slow upward sine wave swell with warm shimmer, warning tone" },
|
||||
{ "file": "event-flood.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Coastal flooding alarm, rushing water surge with distant warning horn" },
|
||||
{ "file": "event-vegetation.mp3","duration": 1.8, "influence": 0.6, "prompt": "Withering plants, dry crackling leaves with sad fading tone, melancholy ambience" },
|
||||
{ "file": "event-water.mp3", "duration": 1.5, "influence": 0.6, "prompt": "Water droplets in empty well, hollow echoing drips, concerning" },
|
||||
{ "file": "event-glacier.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Glacier ice cracking, sharp ice crack with low rumbling calving sound" },
|
||||
{ "file": "event-blackout.mp3", "duration": 1.2, "influence": 0.6, "prompt": "Power outage, electric hum fading out, brief flicker then silence" },
|
||||
{ "file": "event-power-back.mp3","duration": 1.0, "influence": 0.6, "prompt": "Power returns, warm electric hum rising, lights turning back on" },
|
||||
{ "file": "event-tourism.mp3", "duration": 2.0, "influence": 0.6, "prompt": "Sad empty beach, gentle wave on abandoned shore, subtle melancholy accordion" },
|
||||
{ "file": "event-co2.mp3", "duration": 1.5, "influence": 0.6, "prompt": "Industrial CO2 warning, low muffled smokestack rumble with distant alarm" },
|
||||
|
||||
{ "file": "drama-paris-missed.mp3", "duration": 3.0, "influence": 0.6, "prompt": "Dramatic climate alarm, low rumbling bass swell with distant siren and tense strings, building tension, Paris climate goal missed, cinematic but not overwhelming" },
|
||||
{ "file": "drama-disaster.mp3", "duration": 3.5, "influence": 0.6, "prompt": "Heavy disaster impact, ominous orchestra hit with deep brass stab and low rumble, catastrophic warning, dread building" },
|
||||
{ "file": "drama-game-over.mp3", "duration": 4.0, "influence": 0.6, "prompt": "Cinematic failure stinger, slow descending minor chord with deep bass drop, single distant bell toll and fading strings, somber game over, emotional but not cheesy" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user