700 lines
23 KiB
JavaScript
700 lines
23 KiB
JavaScript
/**
|
|
* Persistence Manager Module
|
|
* Handles saving and loading game state and user preferences
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class PersistenceManagerModule extends BaseModule {
|
|
/**
|
|
* Create a new persistence manager
|
|
*/
|
|
constructor() {
|
|
super('persistence-manager', 'Persistence Manager');
|
|
|
|
// Storage keys
|
|
this.keys = {
|
|
gameState: 'ai-interactive-fiction-state',
|
|
preferences: 'ai-interactive-fiction-preferences',
|
|
saveSlots: 'ai-interactive-fiction-saves'
|
|
};
|
|
|
|
// Current game state
|
|
this.gameState = null;
|
|
|
|
// User preferences
|
|
this.preferences = null;
|
|
|
|
// Save slots
|
|
this.saveSlots = {};
|
|
|
|
// Default preferences
|
|
this.defaultPreferences = {
|
|
tts: {
|
|
enabled: false,
|
|
preferred_handler: 'none',
|
|
speed: 1.0,
|
|
language: 'en-us',
|
|
voice: '',
|
|
'elevenlabs-tts_api_key': '',
|
|
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
|
'openai-tts_api_key': '',
|
|
'openai-tts_api_url': 'https://api.openai.com/v1'
|
|
},
|
|
audio: {
|
|
masterVolume: 1.0,
|
|
ttsVolume: 1.0,
|
|
musicVolume: 0.7,
|
|
sfxVolume: 1.0,
|
|
},
|
|
app: {
|
|
locale: 'en-us',
|
|
speed: 1.0,
|
|
}
|
|
};
|
|
|
|
// Bind methods
|
|
this.bindMethods([
|
|
'saveGameState',
|
|
'loadGameState',
|
|
'savePreferences',
|
|
'loadPreferences',
|
|
'getPreference',
|
|
'updatePreference',
|
|
'resetPreferences',
|
|
'createSaveSlot',
|
|
'loadSaveSlot',
|
|
'deleteSaveSlot',
|
|
'getAllSaveSlots',
|
|
'createBinding',
|
|
'updateElementFromPreference',
|
|
'updatePreferenceFromElement',
|
|
'setupBindings'
|
|
]);
|
|
|
|
// Remove circular dependency
|
|
this.dependencies = [];
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, "Initializing persistence manager");
|
|
|
|
// Load preferences first (with default language settings)
|
|
this.loadPreferences();
|
|
|
|
// Load save slots
|
|
this.loadSaveSlots();
|
|
|
|
this.reportProgress(100, "Persistence manager ready");
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error initializing persistence manager:", error);
|
|
this.reportProgress(100, "Persistence manager failed");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the current game state
|
|
* @param {Object} state - Game state to save
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
saveGameState(state) {
|
|
if (!state) return false;
|
|
|
|
try {
|
|
this.gameState = state;
|
|
localStorage.setItem(this.keys.gameState, JSON.stringify(state));
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('game-state-saved', {
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error saving game state:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the current game state
|
|
* @returns {Object|null} - Loaded game state or null if not found
|
|
*/
|
|
loadGameState() {
|
|
try {
|
|
const stateJson = localStorage.getItem(this.keys.gameState);
|
|
if (!stateJson) return null;
|
|
|
|
this.gameState = JSON.parse(stateJson);
|
|
return this.gameState;
|
|
} catch (error) {
|
|
console.error("Error loading game state:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save user preferences
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
savePreferences() {
|
|
if (!this.preferences) return false;
|
|
|
|
try {
|
|
localStorage.setItem(this.keys.preferences, JSON.stringify(this.preferences));
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('preferences-saved', {
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error saving preferences:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load user preferences
|
|
* @returns {Object} - Loaded preferences or default preferences if not found
|
|
*/
|
|
loadPreferences() {
|
|
try {
|
|
const prefsJson = localStorage.getItem(this.keys.preferences);
|
|
|
|
if (prefsJson) {
|
|
// Parse stored preferences
|
|
const storedPrefs = JSON.parse(prefsJson);
|
|
|
|
// Merge with defaults to ensure all keys exist
|
|
this.preferences = this.mergeWithDefaults(storedPrefs, this.defaultPreferences);
|
|
} else {
|
|
// Use defaults if no stored preferences found
|
|
this.preferences = {...this.defaultPreferences};
|
|
}
|
|
|
|
return this.preferences;
|
|
} catch (error) {
|
|
console.error("Error loading preferences:", error);
|
|
this.preferences = {...this.defaultPreferences};
|
|
return this.preferences;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge stored preferences with defaults to ensure all keys exist
|
|
* @param {Object} stored - Stored preferences
|
|
* @param {Object} defaults - Default preferences
|
|
* @returns {Object} - Merged preferences
|
|
*/
|
|
mergeWithDefaults(stored, defaults) {
|
|
// Base case: if stored is not an object or is null, return defaults
|
|
if (typeof stored !== 'object' || stored === null) {
|
|
return defaults;
|
|
}
|
|
|
|
// Create a new object to avoid modifying the input objects
|
|
const merged = {};
|
|
|
|
// Add all keys from defaults, overriding with stored values where they exist
|
|
for (const key in defaults) {
|
|
if (Object.prototype.hasOwnProperty.call(defaults, key)) {
|
|
// If the default value is an object and not null, recurse
|
|
if (typeof defaults[key] === 'object' && defaults[key] !== null) {
|
|
merged[key] = this.mergeWithDefaults(
|
|
Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : {},
|
|
defaults[key]
|
|
);
|
|
} else {
|
|
// Otherwise, use stored value if it exists, otherwise use default
|
|
merged[key] = Object.prototype.hasOwnProperty.call(stored, key) ? stored[key] : defaults[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Get a specific preference
|
|
* @param {string} category - Preference category
|
|
* @param {string} setting - Preference setting
|
|
* @param {*} defaultValue - Default value if preference not found
|
|
* @returns {*} - Preference value
|
|
*/
|
|
getPreference(category, setting, defaultValue = null) {
|
|
if (!category || !setting) return defaultValue;
|
|
|
|
// Ensure preferences are loaded
|
|
if (!this.preferences) {
|
|
this.loadPreferences();
|
|
}
|
|
|
|
// Check if category exists
|
|
if (!this.preferences[category]) return defaultValue;
|
|
|
|
// Check if setting exists in category
|
|
if (!Object.prototype.hasOwnProperty.call(this.preferences[category], setting)) {
|
|
return defaultValue;
|
|
}
|
|
|
|
return this.preferences[category][setting];
|
|
}
|
|
|
|
/**
|
|
* Update a specific preference
|
|
* @param {string} category - Preference category
|
|
* @param {string} setting - Preference setting
|
|
* @param {*} value - New value
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
updatePreference(category, setting, value) {
|
|
if (!this.preferences) return false;
|
|
|
|
// Ensure category exists
|
|
if (!this.preferences[category]) {
|
|
this.preferences[category] = {};
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(this.preferences[category], setting) &&
|
|
Object.is(this.preferences[category][setting], value)) {
|
|
return true;
|
|
}
|
|
|
|
// Store value
|
|
this.preferences[category][setting] = value;
|
|
|
|
// Save preferences
|
|
const success = this.savePreferences();
|
|
console.log("Saved preferences: ", category, setting, value, this.preferences)
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('preference-updated', {
|
|
category,
|
|
key: setting,
|
|
value,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* Reset preferences to defaults
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
resetPreferences() {
|
|
try {
|
|
// Create a deep clone of default preferences
|
|
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
|
|
|
// Save preferences
|
|
const success = this.savePreferences();
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('preferences-reset', {
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return success;
|
|
} catch (error) {
|
|
console.error("Error resetting preferences:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all preferences
|
|
* @returns {Object} - All preferences
|
|
*/
|
|
getAllPreferences() {
|
|
if (!this.preferences) {
|
|
this.loadPreferences();
|
|
}
|
|
|
|
return this.preferences;
|
|
}
|
|
|
|
/**
|
|
* Load save slots
|
|
* @returns {Object} - Save slots
|
|
*/
|
|
loadSaveSlots() {
|
|
try {
|
|
const slotsJson = localStorage.getItem(this.keys.saveSlots);
|
|
|
|
if (slotsJson) {
|
|
this.saveSlots = JSON.parse(slotsJson);
|
|
|
|
// Validate each save slot
|
|
for (const id in this.saveSlots) {
|
|
const slot = this.saveSlots[id];
|
|
if (!slot.id || !slot.name || !slot.timestamp || !slot.state) {
|
|
delete this.saveSlots[id];
|
|
}
|
|
}
|
|
} else {
|
|
this.saveSlots = {};
|
|
}
|
|
|
|
return this.saveSlots;
|
|
} catch (error) {
|
|
console.error("Error loading save slots:", error);
|
|
this.saveSlots = {};
|
|
return this.saveSlots;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save save slots
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
saveSaveSlots() {
|
|
try {
|
|
localStorage.setItem(this.keys.saveSlots, JSON.stringify(this.saveSlots));
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error saving save slots:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new save slot
|
|
* @param {string} name - Save slot name
|
|
* @param {Object} state - Game state to save
|
|
* @returns {string|null} - Save slot ID or null if failed
|
|
*/
|
|
createSaveSlot(name, state) {
|
|
if (!name || !state) return null;
|
|
|
|
try {
|
|
// Generate unique ID
|
|
const id = `save_${Date.now()}`;
|
|
|
|
// Create save slot
|
|
this.saveSlots[id] = {
|
|
id,
|
|
name,
|
|
timestamp: new Date().toISOString(),
|
|
state
|
|
};
|
|
|
|
// Save save slots
|
|
this.saveSaveSlots();
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('save-slot-created', {
|
|
id,
|
|
name,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return id;
|
|
} catch (error) {
|
|
console.error("Error creating save slot:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a save slot
|
|
* @param {string} id - Save slot ID
|
|
* @returns {Object|null} - Game state or null if not found
|
|
*/
|
|
loadSaveSlot(id) {
|
|
if (!id || !this.saveSlots[id]) return null;
|
|
|
|
try {
|
|
const saveSlot = this.saveSlots[id];
|
|
|
|
// Set as current game state
|
|
this.gameState = saveSlot.state;
|
|
|
|
// Save current game state
|
|
this.saveGameState(this.gameState);
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('save-slot-loaded', {
|
|
id,
|
|
name: saveSlot.name,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return this.gameState;
|
|
} catch (error) {
|
|
console.error("Error loading save slot:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a save slot
|
|
* @param {string} id - Save slot ID
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
deleteSaveSlot(id) {
|
|
if (!id || !this.saveSlots[id]) return false;
|
|
|
|
try {
|
|
// Get save slot name before deleting
|
|
const name = this.saveSlots[id].name;
|
|
|
|
// Delete save slot
|
|
delete this.saveSlots[id];
|
|
|
|
// Save save slots
|
|
this.saveSaveSlots();
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('save-slot-deleted', {
|
|
id,
|
|
name,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error deleting save slot:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all save slots
|
|
* @returns {Object} - All save slots
|
|
*/
|
|
getAllSaveSlots() {
|
|
if (!this.saveSlots) {
|
|
this.loadSaveSlots();
|
|
}
|
|
|
|
return this.saveSlots;
|
|
}
|
|
|
|
/**
|
|
* Create a binding between a DOM element and a preference
|
|
* @param {HTMLElement} element - Element to bind to
|
|
* @param {string} category - Preference category
|
|
* @param {string} key - Preference key
|
|
* @param {Object} options - Additional options (transformers, etc)
|
|
* @returns {Object} - Binding control object
|
|
*/
|
|
createBinding(element, category, key, options = {}) {
|
|
if (!element) return null;
|
|
|
|
const transformer = options.transformer || {
|
|
toElement: (value) => value,
|
|
toPreference: (value) => value
|
|
};
|
|
|
|
// Store binding info on the element
|
|
element._prefBinding = { category, key, transformer };
|
|
|
|
// Set initial value
|
|
this.updateElementFromPreference(element);
|
|
|
|
// Set up event listeners
|
|
const eventHandler = () => this.updatePreferenceFromElement(element);
|
|
|
|
// Choose appropriate events based on element type
|
|
let events = ['change'];
|
|
if (element.type !== 'checkbox' && element.type !== 'radio' && element.tagName !== 'SELECT') {
|
|
events.push('input');
|
|
}
|
|
|
|
// Attach event listeners
|
|
events.forEach(event => {
|
|
element.addEventListener(event, eventHandler);
|
|
});
|
|
|
|
// Return control object
|
|
return {
|
|
update: () => this.updateElementFromPreference(element),
|
|
destroy: () => {
|
|
events.forEach(event => {
|
|
element.removeEventListener(event, eventHandler);
|
|
});
|
|
delete element._prefBinding;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update an element value from its bound preference
|
|
* @param {HTMLElement} element - The bound element
|
|
*/
|
|
updateElementFromPreference(element) {
|
|
if (!element || !element._prefBinding) return;
|
|
|
|
const { category, key, transformer } = element._prefBinding;
|
|
const value = this.getPreference(category, key);
|
|
const transformedValue = transformer.toElement(value);
|
|
|
|
// Set element value based on its type
|
|
if (element.type === 'checkbox') {
|
|
element.checked = !!transformedValue;
|
|
} else if (element.type === 'radio') {
|
|
element.checked = element.value === String(transformedValue);
|
|
} else if (element.tagName === 'SELECT') {
|
|
element.value = transformedValue;
|
|
} else {
|
|
element.value = transformedValue;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a preference from its bound element
|
|
* @param {HTMLElement} element - The bound element
|
|
*/
|
|
updatePreferenceFromElement(element) {
|
|
if (!element || !element._prefBinding) return;
|
|
|
|
const { category, key, transformer } = element._prefBinding;
|
|
let value;
|
|
|
|
// Get element value based on its type
|
|
if (element.type === 'checkbox') {
|
|
value = element.checked;
|
|
} else if (element.type === 'radio') {
|
|
value = element.checked ? element.value : null;
|
|
} else {
|
|
value = element.value;
|
|
}
|
|
|
|
const transformedValue = transformer.toPreference(value);
|
|
this.updatePreference(category, key, transformedValue);
|
|
}
|
|
|
|
/**
|
|
* Set up bindings for all elements with data-pref-bind attributes
|
|
* @param {string} rootSelector - Root selector to search within
|
|
* @returns {Array} - Array of binding control objects
|
|
*/
|
|
setupBindings(rootSelector = 'body') {
|
|
const root = document.querySelector(rootSelector);
|
|
if (!root) return [];
|
|
|
|
const bindings = [];
|
|
const elements = root.querySelectorAll('[data-pref-bind]');
|
|
|
|
elements.forEach(element => {
|
|
const bindingStr = element.dataset.prefBind;
|
|
if (!bindingStr) return;
|
|
|
|
const [category, key] = bindingStr.split('.');
|
|
if (!category || !key) return;
|
|
|
|
// Parse transformer if specified
|
|
let transformer = {
|
|
toElement: (value) => value,
|
|
toPreference: (value) => value
|
|
};
|
|
|
|
// Handle range transformations
|
|
if (element.type === 'range' && element.hasAttribute('min') && element.hasAttribute('max')) {
|
|
const min = parseInt(element.getAttribute('min'), 10) || 0;
|
|
const max = parseInt(element.getAttribute('max'), 10) || 100;
|
|
|
|
transformer = {
|
|
toElement: (value) => {
|
|
// Convert from 0-1 to min-max
|
|
return Math.round(value * (max - min) + min);
|
|
},
|
|
toPreference: (value) => {
|
|
// Convert from min-max to 0-1
|
|
return (parseInt(value, 10) - min) / (max - min);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Custom transformer via data attribute
|
|
if (element.dataset.prefTransform) {
|
|
try {
|
|
// Check if it's a range transformer in format 'range:min,max'
|
|
if (element.dataset.prefTransform === 'centered-speed') {
|
|
transformer = {
|
|
toElement: (value) => Math.round(((Number(value) || 1) * 50) + 50),
|
|
toPreference: (value) => Math.max(0.5, Math.min(2.0, (parseInt(value, 10) - 50) / 50))
|
|
};
|
|
} else if (element.dataset.prefTransform === 'multiplier-percent') {
|
|
transformer = {
|
|
toElement: (value) => Math.round((Number(value) || 1) * 100),
|
|
toPreference: (value) => Math.max(0.25, Math.min(4.0, parseInt(value, 10) / 100))
|
|
};
|
|
} else if (element.dataset.prefTransform.startsWith('range:')) {
|
|
const rangeValues = element.dataset.prefTransform.substring(6).split(',');
|
|
if (rangeValues.length === 2) {
|
|
const min = parseFloat(rangeValues[0]);
|
|
const max = parseFloat(rangeValues[1]);
|
|
|
|
if (!isNaN(min) && !isNaN(max)) {
|
|
transformer = {
|
|
toElement: (value) => {
|
|
// Convert from min-max to 0-100 for the slider
|
|
return Math.round(((value - min) / (max - min)) * 100);
|
|
},
|
|
toPreference: (value) => {
|
|
// Convert from 0-100 to min-max
|
|
return min + (parseInt(value, 10) / 100) * (max - min);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
// Try to parse as JSON for backward compatibility
|
|
const customTransformer = JSON.parse(element.dataset.prefTransform);
|
|
if (customTransformer && typeof customTransformer === 'object') {
|
|
transformer = customTransformer;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Invalid transformer data attribute', e);
|
|
}
|
|
}
|
|
|
|
const binding = this.createBinding(element, category, key, { transformer });
|
|
if (binding) {
|
|
bindings.push(binding);
|
|
}
|
|
});
|
|
|
|
// Set up event listener for preference changes from other sources
|
|
document.addEventListener('preference-updated', (event) => {
|
|
const { category, key } = event.detail;
|
|
|
|
// Update any matching elements
|
|
elements.forEach(element => {
|
|
if (!element._prefBinding) return;
|
|
|
|
if (element._prefBinding.category === category &&
|
|
element._prefBinding.key === key) {
|
|
this.updateElementFromPreference(element);
|
|
}
|
|
});
|
|
});
|
|
|
|
return bindings;
|
|
}
|
|
|
|
/**
|
|
* Clean up when module is disposed
|
|
*/
|
|
dispose() {
|
|
// Nothing to clean up
|
|
}
|
|
}
|
|
|
|
// Create the singleton instance
|
|
const PersistenceManager = new PersistenceManagerModule();
|
|
|
|
// Export the module
|
|
export { PersistenceManager };
|