/** * Dauerhafte Einstellungen im Browser (localStorage) – ohne Phaser. * * localStorage kann fehlen oder Ausnahmen werfen (privater Modus, blockierte * Website-Daten, volle Quote). Das Spiel muss dann trotzdem laufen: Werte bleiben * in diesem Fall nur für die aktuelle Sitzung im Speicher. * @module systems/Settings */ import { AUDIO, HIGHSCORE, STORAGE_KEYS } from '../config.js'; import { clamp } from '../logic/math.js'; /** @typedef {'master'|'music'|'sfx'} VolumeChannel */ /** @type {readonly VolumeChannel[]} */ export const VOLUME_CHANNELS = Object.freeze(['master', 'music', 'sfx']); /** * Schlüssel-Wert-Speicher mit JSON und Fallback auf den Arbeitsspeicher. */ export class SafeStorage { /** @param {Storage|null|undefined} backend */ constructor(backend) { this.backend = backend ?? null; /** @type {Map} */ this.memory = new Map(); } /** * Erzeugt einen Speicher für den Browser; der Zugriff auf `localStorage` selbst * kann bereits eine Ausnahme werfen (z. B. bei gesperrten Cookies). * @returns {SafeStorage} */ static forBrowser() { try { return new SafeStorage(globalThis.localStorage); } catch { return new SafeStorage(null); } } /** * @template T * @param {string} key * @param {T} fallback * @returns {T} */ get(key, fallback) { let raw = this.memory.get(key) ?? null; try { raw = this.backend?.getItem(key) ?? raw; } catch { // Browser verweigert den Zugriff – Speicherwert verwenden. } if (raw === null) return fallback; try { return JSON.parse(raw); } catch { return fallback; } } /** * @param {string} key * @param {unknown} value * @returns {boolean} true, wenn dauerhaft gespeichert wurde. */ set(key, value) { const raw = JSON.stringify(value); this.memory.set(key, raw); try { this.backend?.setItem(key, raw); return this.backend !== null; } catch { return false; } } } /** * Spielereinstellungen: Disclaimer-Bestätigung, Lautstärken, letzter Name. */ export class Settings { /** @param {SafeStorage} storage */ constructor(storage) { this.storage = storage; /** @type {Set<(channel: VolumeChannel, value: number) => void>} */ this.volumeListeners = new Set(); const stored = storage.get(STORAGE_KEYS.volumes, {}); /** @type {Record} */ this.volumes = { ...AUDIO.defaultVolumes }; for (const channel of VOLUME_CHANNELS) { const value = stored?.[channel]; if (typeof value === 'number' && Number.isFinite(value)) { this.volumes[channel] = clamp(value, 0, 1); } } } /** @returns {boolean} */ isDisclaimerAccepted() { return this.storage.get(STORAGE_KEYS.disclaimerAccepted, false) === true; } acceptDisclaimer() { this.storage.set(STORAGE_KEYS.disclaimerAccepted, true); } /** * @param {VolumeChannel} channel * @returns {number} 0..1 */ getVolume(channel) { return this.volumes[channel]; } /** * Effektive Lautstärke eines Kanals (inkl. Gesamtlautstärke). * @param {'music'|'sfx'} channel * @returns {number} */ effectiveVolume(channel) { return this.volumes.master * this.volumes[channel]; } /** * @param {VolumeChannel} channel * @param {number} value 0..1, wird begrenzt. */ setVolume(channel, value) { if (!VOLUME_CHANNELS.includes(channel)) return; const safe = Number.isFinite(value) ? clamp(value, 0, 1) : this.volumes[channel]; this.volumes[channel] = safe; this.storage.set(STORAGE_KEYS.volumes, this.volumes); for (const listener of this.volumeListeners) listener(channel, safe); } /** * @param {(channel: VolumeChannel, value: number) => void} listener * @returns {() => void} Funktion zum Abmelden. */ onVolumeChange(listener) { this.volumeListeners.add(listener); return () => this.volumeListeners.delete(listener); } /** @returns {string} */ getLastName() { const name = this.storage.get(STORAGE_KEYS.lastName, ''); return typeof name === 'string' ? name.slice(0, HIGHSCORE.nameMaxLength) : ''; } /** @param {string} name */ setLastName(name) { this.storage.set(STORAGE_KEYS.lastName, String(name).slice(0, HIGHSCORE.nameMaxLength)); } }