158 lines
4.3 KiB
JavaScript
158 lines
4.3 KiB
JavaScript
/**
|
||
* 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<string, string>} */
|
||
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<VolumeChannel, number>} */
|
||
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));
|
||
}
|
||
}
|