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,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