0a2ebf46b4
Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails, Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform, Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau- Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf), Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md). Plattform: PHP-Wrapper mit base-Tag + Session-Mode, Modul-Detailseite, module_info (beta) + Glossar (14 Begriffe inkl. Leichter Sprache + Quellen) + Lehrplan-Anker AT/DE/CH an neuer Kompetenz tourismus-raumentwicklung. Lehrer-Backend: Benchmark-Formel, Live-Cockpit-Spalten, needsHelp-Heuristik, Modul-Highlights. Strategie-Testharness (tests/strategien.mjs) belegt: Panzer-Rush wird liquidiert, Ruecklagen zahlen sich aus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
4.7 KiB
JavaScript
158 lines
4.7 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', 'amb_birds', 'amb_cow', 'amb_cowbell', 'amb_wind'];
|
||
|
||
export const audio = {
|
||
ctx: null,
|
||
soundOn: true,
|
||
musicOn: true,
|
||
_buffers: {}, // Name → AudioBuffer
|
||
_raw: {}, // Name → ArrayBuffer (vor dem Decodieren)
|
||
_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;
|
||
})
|
||
.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);
|
||
}
|
||
},
|
||
|
||
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);
|
||
}
|
||
},
|
||
|
||
// ---------- Gelegentliche Naturlaute (statt Dauer-Ambience) ----------
|
||
// Alle 10–25 s ein kurzer Laut passend zur Saison: Vögel, Kuhglocken,
|
||
// ein Muhen – im Winter meist nur ein Windhauch.
|
||
_ambTimer: null,
|
||
|
||
setSeason(season) {
|
||
this._season = season;
|
||
},
|
||
|
||
_pickAmbience() {
|
||
const r = Math.random();
|
||
if (this._season === 'Winter') {
|
||
return r < 0.6 ? 'amb_wind' : r < 0.85 ? 'amb_cowbell' : 'amb_birds';
|
||
}
|
||
return r < 0.45 ? 'amb_birds' : r < 0.75 ? 'amb_cowbell' : 'amb_cow';
|
||
},
|
||
|
||
_scheduleAmbience() {
|
||
this._ambTimer = setTimeout(() => {
|
||
if (this.soundOn && this.ctx) {
|
||
const name = this._pickAmbience();
|
||
const buf = this._buffers[name];
|
||
if (buf) {
|
||
const src = this.ctx.createBufferSource();
|
||
const g = this.ctx.createGain();
|
||
g.gain.value = 0.22;
|
||
src.buffer = buf;
|
||
src.connect(g).connect(this.ctx.destination);
|
||
src.start();
|
||
}
|
||
}
|
||
this._scheduleAmbience();
|
||
}, 10000 + Math.random() * 15000);
|
||
},
|
||
|
||
startMusic() {
|
||
if (!this.ensure()) return;
|
||
clearTimeout(this._ambTimer);
|
||
this._scheduleAmbience();
|
||
},
|
||
|
||
_stopAmbience() {
|
||
clearTimeout(this._ambTimer);
|
||
this._ambTimer = null;
|
||
},
|
||
|
||
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();
|