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:
2026-04-18 23:50:17 +02:00
parent b2721d759c
commit dfb9e80664
91 changed files with 9034 additions and 0 deletions
+125
View File
@@ -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())
+156
View File
@@ -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 &lt;name&gt; --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>
+44
View File
@@ -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" }
]
}