8047778a5b
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal- Playbook integriert: Sim nach App/sims/tourismusregion (game.html, Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys), Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung, Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home- Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info (beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus- raumentwicklung (primaer) + verkehrserziehung/systemisches-denken. LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer- Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten, needsHelp, Highlights. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
6.1 KiB
JavaScript
188 lines
6.1 KiB
JavaScript
// Klang: KI-generierte Samples (ElevenLabs, assets/sounds/*.mp3) mit
|
|
// WebAudio abgespielt; fehlt eine Datei, greift ein kleiner Synth-Fallback.
|
|
// Dazu saisonale Atmosphäre (Sommerwiese/Winterwind) als Loop.
|
|
// AudioContext entsteht erst nach der ersten Nutzer-Geste (iOS/Safari).
|
|
|
|
const SAMPLES = ['click', 'build', 'demolish', 'success', 'error',
|
|
'card', 'milestone', 'win', 'ambience_summer', 'ambience_winter',
|
|
'bite', 'sip', 'splash', 'tool', 'cash'];
|
|
|
|
export const audio = {
|
|
ctx: null,
|
|
soundOn: true,
|
|
musicOn: true,
|
|
_buffers: {}, // Name → AudioBuffer
|
|
_raw: {}, // Name → ArrayBuffer (vor dem Decodieren)
|
|
_ambience: null, // laufender Ambience-Loop {src, gain, name}
|
|
_season: 'Sommer',
|
|
|
|
// MP3s sofort laden (ohne AudioContext), decodiert wird bei ensure()
|
|
preload() {
|
|
for (const name of SAMPLES) {
|
|
fetch(`assets/sounds/${name}.mp3`)
|
|
.then(r => (r.ok ? r.arrayBuffer() : null))
|
|
.then(buf => { if (buf) { this._raw[name] = buf; this._decode(name); } })
|
|
.catch(() => {});
|
|
}
|
|
},
|
|
|
|
_decode(name) {
|
|
if (!this.ctx || !this._raw[name] || this._buffers[name]) return;
|
|
this.ctx.decodeAudioData(this._raw[name].slice(0))
|
|
.then(b => {
|
|
this._buffers[name] = b;
|
|
// Ambience ggf. nachstarten, sobald sie decodiert ist
|
|
if (name === this._ambienceName() && this.musicOn && !this._ambience) {
|
|
this._startAmbience();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
},
|
|
|
|
ensure() {
|
|
if (!this.ctx) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return false;
|
|
this.ctx = new AC();
|
|
for (const name of Object.keys(this._raw)) this._decode(name);
|
|
}
|
|
if (this.ctx.state === 'suspended') this.ctx.resume();
|
|
return true;
|
|
},
|
|
|
|
// ---------- Effekte ----------
|
|
play(name, gain = 0.5) {
|
|
if (!this.soundOn || !this.ensure()) return;
|
|
const buf = this._buffers[name];
|
|
if (buf) {
|
|
const src = this.ctx.createBufferSource();
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = gain;
|
|
src.buffer = buf;
|
|
src.connect(g).connect(this.ctx.destination);
|
|
src.start();
|
|
} else {
|
|
this._synthFallback(name);
|
|
}
|
|
},
|
|
|
|
// kurzer, leiser Interaktions-Ton (Tower-Defense-„Treffer"), pitch = Faktor
|
|
pling(pitch = 1) {
|
|
if (!this.soundOn || !this.ensure()) return;
|
|
this._tone(620 * pitch, 0.09, 'triangle', 0.035);
|
|
},
|
|
|
|
// Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), dezent
|
|
fx(name) {
|
|
if (!this.soundOn || !this.ensure()) return;
|
|
const buf = this._buffers[name];
|
|
if (buf) {
|
|
const src = this.ctx.createBufferSource();
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = 0.28;
|
|
src.buffer = buf;
|
|
src.connect(g).connect(this.ctx.destination);
|
|
src.start();
|
|
} else {
|
|
// Synth-Fallback je Art
|
|
switch (name) {
|
|
case 'bite': this._tone(140, 0.08, 'square', 0.04); break;
|
|
case 'sip': this._tone(320, 0.14, 'sine', 0.03); break;
|
|
case 'splash': this._tone(900, 0.10, 'sine', 0.03); this._tone(500, 0.12, 'sine', 0.02, 0.03); break;
|
|
case 'tool': this._tone(220, 0.05, 'sawtooth', 0.03); this._tone(260, 0.05, 'sawtooth', 0.03, 0.06); break;
|
|
case 'cash': this._tone(880, 0.06, 'triangle', 0.04); this._tone(1240, 0.10, 'triangle', 0.035, 0.06); break;
|
|
default: this.pling();
|
|
}
|
|
}
|
|
},
|
|
|
|
click() { this.play('click', 0.35); },
|
|
success() { this.play('success', 0.5); },
|
|
error() { this.play('error', 0.45); },
|
|
chime() { this.play('card', 0.45); },
|
|
card() { this.chime(); },
|
|
build() { this.play('build', 0.55); },
|
|
demolish() { this.play('demolish', 0.55); },
|
|
milestone() { this.play('milestone', 0.55); },
|
|
win() { this.play('win', 0.6); },
|
|
|
|
_tone(freq, dur, type = 'sine', gain = 0.08, delay = 0) {
|
|
const t0 = this.ctx.currentTime + delay;
|
|
const osc = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.value = freq;
|
|
g.gain.setValueAtTime(0, t0);
|
|
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
|
|
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
|
|
osc.connect(g).connect(this.ctx.destination);
|
|
osc.start(t0);
|
|
osc.stop(t0 + dur + 0.05);
|
|
},
|
|
|
|
_synthFallback(name) {
|
|
switch (name) {
|
|
case 'click': this._tone(660, 0.08, 'triangle', 0.05); break;
|
|
case 'success': case 'build': case 'milestone': case 'win':
|
|
this._tone(523, 0.12, 'triangle', 0.07);
|
|
this._tone(784, 0.18, 'triangle', 0.07, 0.09);
|
|
break;
|
|
case 'error': case 'demolish': this._tone(180, 0.22, 'sawtooth', 0.05); break;
|
|
default: this._tone(880, 0.3, 'sine', 0.05);
|
|
}
|
|
},
|
|
|
|
// ---------- Saisonale Atmosphäre (läuft, wenn Musik an ist) ----------
|
|
_ambienceName() {
|
|
return this._season === 'Winter' ? 'ambience_winter' : 'ambience_summer';
|
|
},
|
|
|
|
setSeason(season) {
|
|
if (season === this._season) return;
|
|
this._season = season;
|
|
if (this.musicOn && this.ctx) this._startAmbience(); // Wechsel mit Fade
|
|
},
|
|
|
|
_startAmbience() {
|
|
const name = this._ambienceName();
|
|
const buf = this._buffers[name];
|
|
if (!buf) return;
|
|
this._stopAmbience();
|
|
const src = this.ctx.createBufferSource();
|
|
const g = this.ctx.createGain();
|
|
src.buffer = buf;
|
|
src.loop = true;
|
|
g.gain.setValueAtTime(0, this.ctx.currentTime);
|
|
g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime + 2.5);
|
|
src.connect(g).connect(this.ctx.destination);
|
|
src.start();
|
|
this._ambience = { src, gain: g, name };
|
|
},
|
|
|
|
_stopAmbience() {
|
|
if (!this._ambience) return;
|
|
const { src, gain } = this._ambience;
|
|
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 1.2);
|
|
setTimeout(() => { try { src.stop(); } catch (e) { /* schon gestoppt */ } }, 1400);
|
|
this._ambience = null;
|
|
},
|
|
|
|
startMusic() {
|
|
if (!this.musicOn || !this.ensure()) return;
|
|
this._startAmbience();
|
|
},
|
|
|
|
toggleMusic() {
|
|
this.musicOn = !this.musicOn;
|
|
if (this.musicOn) this.startMusic(); else this._stopAmbience();
|
|
return this.musicOn;
|
|
},
|
|
|
|
toggleSound() {
|
|
this.soundOn = !this.soundOn;
|
|
return this.soundOn;
|
|
},
|
|
};
|
|
|
|
audio.preload();
|