3014 lines
117 KiB
JavaScript
3014 lines
117 KiB
JavaScript
// Game state management
|
|
class TaskChallengeGame {
|
|
constructor() {
|
|
// Initialize data management system first
|
|
this.dataManager = new DataManager();
|
|
|
|
// Initialize desktop features early
|
|
this.initDesktopFeatures();
|
|
|
|
this.gameState = {
|
|
isRunning: false,
|
|
isPaused: false,
|
|
currentTask: null,
|
|
isConsequenceTask: false,
|
|
startTime: null,
|
|
pausedTime: 0,
|
|
totalPausedTime: 0,
|
|
completedCount: 0,
|
|
skippedCount: 0,
|
|
consequenceCount: 0,
|
|
score: 0,
|
|
lastSkippedTask: null, // Track the last skipped task for mercy cost calculation
|
|
usedMainTasks: [],
|
|
usedConsequenceTasks: []
|
|
};
|
|
|
|
this.timerInterval = null;
|
|
this.imageDiscoveryComplete = false;
|
|
this.musicManager = new MusicManager(this.dataManager);
|
|
this.initializeEventListeners();
|
|
this.setupKeyboardShortcuts();
|
|
this.initializeCustomTasks();
|
|
this.discoverImages().then(() => {
|
|
this.showScreen('start-screen');
|
|
});
|
|
|
|
// Check for auto-resume after initialization
|
|
this.checkAutoResume();
|
|
}
|
|
|
|
async initDesktopFeatures() {
|
|
// Initialize desktop file manager
|
|
if (typeof DesktopFileManager !== 'undefined') {
|
|
this.fileManager = new DesktopFileManager(this.dataManager);
|
|
window.desktopFileManager = this.fileManager;
|
|
|
|
// Auto-scan directories on startup
|
|
setTimeout(async () => {
|
|
if (this.fileManager && this.fileManager.isElectron) {
|
|
console.log('🔍 Auto-scanning directories on startup...');
|
|
await this.fileManager.scanAllDirectories();
|
|
}
|
|
}, 1000); // Wait 1 second for initialization to complete
|
|
}
|
|
|
|
// Check if we're in Electron and update UI accordingly
|
|
setTimeout(() => {
|
|
const isElectron = window.electronAPI !== undefined;
|
|
|
|
if (isElectron) {
|
|
document.body.classList.add('desktop-mode');
|
|
// Show desktop-specific features
|
|
document.querySelectorAll('.desktop-only').forEach(el => el.style.display = '');
|
|
document.querySelectorAll('.desktop-feature').forEach(el => el.style.display = '');
|
|
document.querySelectorAll('.web-feature').forEach(el => el.style.display = 'none');
|
|
console.log('🖥️ Desktop mode activated');
|
|
} else {
|
|
document.body.classList.add('web-mode');
|
|
// Hide desktop-only features
|
|
document.querySelectorAll('.desktop-only').forEach(el => el.style.display = 'none');
|
|
document.querySelectorAll('.desktop-feature').forEach(el => el.style.display = 'none');
|
|
document.querySelectorAll('.web-feature').forEach(el => el.style.display = '');
|
|
console.log('🌐 Web mode activated');
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
initializeCustomTasks() {
|
|
// Load custom tasks from localStorage or use defaults
|
|
const savedMainTasks = localStorage.getItem('customMainTasks');
|
|
const savedConsequenceTasks = localStorage.getItem('customConsequenceTasks');
|
|
|
|
if (savedMainTasks) {
|
|
gameData.mainTasks = JSON.parse(savedMainTasks);
|
|
}
|
|
|
|
if (savedConsequenceTasks) {
|
|
gameData.consequenceTasks = JSON.parse(savedConsequenceTasks);
|
|
}
|
|
|
|
console.log(`Loaded ${gameData.mainTasks.length} main tasks and ${gameData.consequenceTasks.length} consequence tasks`);
|
|
}
|
|
|
|
async discoverImages() {
|
|
try {
|
|
console.log('Loading embedded image manifest...');
|
|
|
|
// Get the current embedded manifest (empty by default)
|
|
let manifest = this.getEmbeddedManifest();
|
|
|
|
// Only scan if the user has explicitly requested it via the scan button
|
|
// No automatic scanning on startup
|
|
console.log('Skipping automatic scan - user must manually scan or upload images');
|
|
|
|
// Build full paths and verify images exist
|
|
gameData.discoveredTaskImages = await this.verifyImagesFromManifest(manifest.tasks, 'images/tasks/');
|
|
gameData.discoveredConsequenceImages = await this.verifyImagesFromManifest(manifest.consequences, 'images/consequences/');
|
|
|
|
console.log(`Task images found: ${gameData.discoveredTaskImages.length}`);
|
|
console.log(`Consequence images found: ${gameData.discoveredConsequenceImages.length}`);
|
|
|
|
} catch (error) {
|
|
console.log('Image manifest loading failed:', error);
|
|
// Don't fallback to pattern detection - keep empty
|
|
gameData.discoveredTaskImages = [];
|
|
gameData.discoveredConsequenceImages = [];
|
|
}
|
|
this.imageDiscoveryComplete = true;
|
|
console.log('Image discovery completed');
|
|
}
|
|
|
|
getEmbeddedManifest() {
|
|
// Empty manifest - users must upload or scan for their own images
|
|
return {
|
|
"tasks": [],
|
|
"consequences": []
|
|
};
|
|
}
|
|
|
|
async updateManifestWithNewImages(manifest) {
|
|
console.log('Scanning for new images...');
|
|
|
|
// Get cached manifest or current one
|
|
let cachedManifest = this.dataManager.get('cachedManifest');
|
|
if (!cachedManifest) {
|
|
cachedManifest = {...manifest}; // Copy original
|
|
}
|
|
|
|
// Scan for new images using pattern detection
|
|
const newTaskImages = await this.scanDirectoryForNewImages('images/tasks/', cachedManifest.tasks);
|
|
const newConsequenceImages = await this.scanDirectoryForNewImages('images/consequences/', cachedManifest.consequences);
|
|
|
|
// Add any new images found
|
|
if (newTaskImages.length > 0) {
|
|
cachedManifest.tasks = [...new Set([...cachedManifest.tasks, ...newTaskImages])]; // Remove duplicates
|
|
console.log(`Found ${newTaskImages.length} new task images:`, newTaskImages);
|
|
}
|
|
|
|
if (newConsequenceImages.length > 0) {
|
|
cachedManifest.consequences = [...new Set([...cachedManifest.consequences, ...newConsequenceImages])]; // Remove duplicates
|
|
console.log(`Found ${newConsequenceImages.length} new consequence images:`, newConsequenceImages);
|
|
}
|
|
|
|
// Save updated manifest to localStorage
|
|
this.dataManager.set('cachedManifest', cachedManifest);
|
|
|
|
return cachedManifest;
|
|
}
|
|
|
|
async scanDirectoryForNewImages(directory, knownImages) {
|
|
const newImages = [];
|
|
|
|
// Comprehensive pattern scan for new images
|
|
const patterns = [
|
|
// Numbers 1-100
|
|
...Array.from({length: 100}, (_, i) => (i + 1).toString()),
|
|
// Image patterns
|
|
...Array.from({length: 50}, (_, i) => `image${i + 1}`),
|
|
...Array.from({length: 50}, (_, i) => `img${i + 1}`),
|
|
...Array.from({length: 50}, (_, i) => `photo${i + 1}`),
|
|
...Array.from({length: 50}, (_, i) => `pic${i + 1}`),
|
|
// Date-based patterns (common camera formats)
|
|
...Array.from({length: 50}, (_, i) => `IMG_${20210101 + i}`),
|
|
...Array.from({length: 50}, (_, i) => `DSC${String(i + 1).padStart(4, '0')}`),
|
|
// Letters a-z
|
|
...Array.from({length: 26}, (_, i) => String.fromCharCode(97 + i)),
|
|
// Random common names
|
|
'new', 'test', 'sample', 'demo', 'example', 'temp', 'screenshot', 'capture',
|
|
// UUID-like patterns
|
|
...Array.from({length: 20}, (_, i) => `img_${Date.now() + i}`)
|
|
];
|
|
|
|
console.log(`Scanning ${directory} for new images (${patterns.length} patterns)...`);
|
|
|
|
// Test each pattern with each supported format
|
|
let checkedCount = 0;
|
|
const maxChecks = 500; // Reasonable limit to prevent hanging
|
|
|
|
for (const pattern of patterns) {
|
|
if (checkedCount >= maxChecks) {
|
|
console.log(`Reached maximum scan limit (${maxChecks} checks)`);
|
|
break;
|
|
}
|
|
|
|
for (const format of gameData.supportedImageFormats) {
|
|
checkedCount++;
|
|
const filename = `${pattern}${format}`;
|
|
|
|
// Skip if we already know about this image
|
|
if (knownImages.includes(filename)) {
|
|
continue;
|
|
}
|
|
|
|
const imagePath = `${directory}${filename}`;
|
|
const exists = await this.checkImageExists(imagePath);
|
|
if (exists) {
|
|
newImages.push(filename);
|
|
console.log('✓ Found NEW image:', filename);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Scanned ${checkedCount} possibilities, found ${newImages.length} new images`);
|
|
return newImages;
|
|
}
|
|
|
|
async verifyImagesFromManifest(imageList, directory) {
|
|
const validImages = [];
|
|
|
|
for (const imageName of imageList) {
|
|
const imagePath = `${directory}${imageName}`;
|
|
const exists = await this.checkImageExists(imagePath);
|
|
if (exists) {
|
|
validImages.push(imagePath);
|
|
console.log('✓ Verified image:', imagePath);
|
|
} else {
|
|
console.log('✗ Missing image:', imagePath);
|
|
}
|
|
}
|
|
|
|
return validImages;
|
|
}
|
|
|
|
async fallbackImageDiscovery() {
|
|
// Fallback to simplified pattern detection if manifest fails
|
|
console.log('Using fallback pattern detection...');
|
|
|
|
const commonPatterns = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
|
|
|
|
gameData.discoveredTaskImages = await this.findImagesWithPatterns('images/tasks/', commonPatterns);
|
|
gameData.discoveredConsequenceImages = await this.findImagesWithPatterns('images/consequences/', commonPatterns);
|
|
|
|
// If still no images found, show helpful message but don't use placeholders
|
|
if (gameData.discoveredTaskImages.length === 0 && gameData.discoveredConsequenceImages.length === 0) {
|
|
console.log('No images found. Users will need to upload or scan for images.');
|
|
gameData.discoveredTaskImages = [];
|
|
gameData.discoveredConsequenceImages = [];
|
|
}
|
|
}
|
|
|
|
async findImagesWithPatterns(directory, patterns) {
|
|
const foundImages = [];
|
|
|
|
for (const pattern of patterns) {
|
|
for (const format of gameData.supportedImageFormats) {
|
|
const imagePath = `${directory}${pattern}${format}`;
|
|
const exists = await this.checkImageExists(imagePath);
|
|
if (exists) {
|
|
foundImages.push(imagePath);
|
|
console.log('✓ Found image:', imagePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return foundImages;
|
|
}
|
|
|
|
setupPlaceholderImages() {
|
|
gameData.discoveredTaskImages = [this.createPlaceholderImage('Task Image')];
|
|
gameData.discoveredConsequenceImages = [this.createPlaceholderImage('Consequence Image')];
|
|
}
|
|
|
|
checkImageExists(imagePath) {
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
console.log(`✓ Found image: ${imagePath}`);
|
|
resolve(true);
|
|
};
|
|
img.onerror = () => {
|
|
console.log(`✗ Missing image: ${imagePath}`);
|
|
resolve(false);
|
|
};
|
|
img.src = imagePath;
|
|
|
|
// Timeout after 1 second (faster discovery)
|
|
setTimeout(() => {
|
|
console.log(`⏰ Timeout for image: ${imagePath}`);
|
|
resolve(false);
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
createPlaceholderImage(label = 'Task Image') {
|
|
const encodedLabel = encodeURIComponent(label);
|
|
return `data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIiBzdHJva2U9IiNjY2MiIHN0cm9rZS13aWR0aD0iMiIvPjx0ZXh0IHg9IjUwJSIgeT0iNDAlIiBmb250LXNpemU9IjE2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZmlsbD0iIzY2NiI+JHtsYWJlbH08L3RleHQ+PHRleHQgeD0iNTAlIiB5PSI2MCUiIGZvbnQtc2l6ZT0iMTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjOTk5Ij5BZGQgaW1hZ2VzIHRvIGZvbGRlcjwvdGV4dD48L3N2Zz4=`;
|
|
}
|
|
|
|
initializeEventListeners() {
|
|
// Screen navigation
|
|
document.getElementById('start-btn').addEventListener('click', () => this.startGame());
|
|
document.getElementById('resume-btn').addEventListener('click', () => this.resumeGame());
|
|
document.getElementById('quit-btn').addEventListener('click', () => this.quitGame());
|
|
document.getElementById('play-again-btn').addEventListener('click', () => this.resetGame());
|
|
|
|
// Game actions
|
|
document.getElementById('complete-btn').addEventListener('click', () => this.completeTask());
|
|
document.getElementById('skip-btn').addEventListener('click', () => this.skipTask());
|
|
document.getElementById('mercy-skip-btn').addEventListener('click', () => this.mercySkip());
|
|
document.getElementById('pause-btn').addEventListener('click', () => this.pauseGame());
|
|
|
|
// Theme selector
|
|
document.getElementById('theme-dropdown').addEventListener('change', (e) => this.changeTheme(e.target.value));
|
|
|
|
// Music controls
|
|
document.getElementById('music-toggle').addEventListener('click', () => this.toggleMusic());
|
|
document.getElementById('loop-btn').addEventListener('click', () => this.toggleLoop());
|
|
document.getElementById('shuffle-btn').addEventListener('click', () => this.toggleShuffle());
|
|
document.getElementById('track-selector').addEventListener('change', (e) => this.changeTrack(parseInt(e.target.value)));
|
|
document.getElementById('volume-slider').addEventListener('input', (e) => this.changeVolume(parseInt(e.target.value)));
|
|
|
|
// Task management
|
|
document.getElementById('manage-tasks-btn').addEventListener('click', () => this.showTaskManagement());
|
|
document.getElementById('back-to-start-btn').addEventListener('click', () => this.showScreen('start-screen'));
|
|
document.getElementById('add-task-btn').addEventListener('click', () => this.addNewTask());
|
|
document.getElementById('reset-tasks-btn').addEventListener('click', () => this.resetToDefaultTasks());
|
|
document.getElementById('main-tasks-tab').addEventListener('click', () => this.showTaskTab('main'));
|
|
document.getElementById('consequence-tasks-tab').addEventListener('click', () => this.showTaskTab('consequence'));
|
|
document.getElementById('new-task-type').addEventListener('change', () => this.toggleDifficultyDropdown());
|
|
|
|
// Data management
|
|
document.getElementById('export-btn').addEventListener('click', () => this.exportData());
|
|
document.getElementById('import-btn').addEventListener('click', () => this.importData());
|
|
document.getElementById('import-file').addEventListener('change', (e) => this.handleFileImport(e));
|
|
document.getElementById('stats-btn').addEventListener('click', () => this.showStats());
|
|
document.getElementById('help-btn').addEventListener('click', () => this.showHelp());
|
|
document.getElementById('close-stats').addEventListener('click', () => this.hideStats());
|
|
document.getElementById('close-help').addEventListener('click', () => this.hideHelp());
|
|
document.getElementById('reset-stats-btn').addEventListener('click', () => this.resetStats());
|
|
document.getElementById('export-stats-btn').addEventListener('click', () => this.exportStatsOnly());
|
|
|
|
// Image management - only the main button, others will be attached when screen is shown
|
|
document.getElementById('manage-images-btn').addEventListener('click', () => this.showImageManagement());
|
|
|
|
// Load saved theme
|
|
this.loadSavedTheme();
|
|
}
|
|
|
|
setupKeyboardShortcuts() {
|
|
document.addEventListener('keydown', (e) => {
|
|
// Don't trigger shortcuts when typing in inputs or textareas
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
return;
|
|
}
|
|
|
|
switch(e.key.toLowerCase()) {
|
|
case ' ': // Spacebar - pause/unpause game
|
|
e.preventDefault();
|
|
if (this.gameState.isRunning) {
|
|
this.pauseGame();
|
|
} else if (this.gameState.isPaused) {
|
|
this.resumeGame();
|
|
}
|
|
break;
|
|
|
|
case 'p': // P key - pause/unpause game
|
|
e.preventDefault();
|
|
if (this.gameState.isRunning) {
|
|
this.pauseGame();
|
|
} else if (this.gameState.isPaused) {
|
|
this.resumeGame();
|
|
}
|
|
break;
|
|
|
|
case 'm': // M key - toggle music
|
|
e.preventDefault();
|
|
this.toggleMusic();
|
|
break;
|
|
|
|
case 'escape': // Escape key - close modals or return to start
|
|
e.preventDefault();
|
|
// Close help modal if open
|
|
const helpModal = document.getElementById('help-modal');
|
|
if (helpModal && helpModal.style.display !== 'none') {
|
|
this.hideHelp();
|
|
return;
|
|
}
|
|
|
|
// Close stats modal if open
|
|
const statsModal = document.getElementById('stats-modal');
|
|
if (statsModal && !statsModal.hidden) {
|
|
this.hideStats();
|
|
return;
|
|
}
|
|
|
|
// If in task management, go back to start screen
|
|
if (document.getElementById('task-management-screen').style.display !== 'none') {
|
|
this.showScreen('start-screen');
|
|
return;
|
|
}
|
|
|
|
// If in game, pause it
|
|
if (this.gameState.isRunning) {
|
|
this.pauseGame();
|
|
}
|
|
break;
|
|
|
|
case 'h': // H key - toggle help menu
|
|
e.preventDefault();
|
|
this.toggleHelp();
|
|
break;
|
|
|
|
case 'enter': // Enter key - complete task
|
|
e.preventDefault();
|
|
if (this.gameState.isRunning && !this.gameState.isPaused) {
|
|
this.completeTask();
|
|
}
|
|
break;
|
|
|
|
case 'control': // Ctrl key - regular skip only
|
|
e.preventDefault();
|
|
if (this.gameState.isRunning && !this.gameState.isPaused) {
|
|
// Check for Shift+Ctrl for mercy skip
|
|
if (e.shiftKey) {
|
|
const mercyBtn = document.getElementById('mercy-skip-btn');
|
|
if (mercyBtn && mercyBtn.style.display !== 'none' && !mercyBtn.disabled) {
|
|
this.mercySkip();
|
|
}
|
|
} else {
|
|
this.skipTask();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Show keyboard shortcuts hint
|
|
console.log('Keyboard shortcuts enabled: Enter (complete), Ctrl (skip), Shift+Ctrl (mercy skip), Space/P (pause), M (music), H (help), Escape (close/back)');
|
|
}
|
|
|
|
checkAutoResume() {
|
|
try {
|
|
// Check if there's a saved game state to resume
|
|
const savedGameState = this.dataManager.get('autoSaveGameState');
|
|
|
|
if (savedGameState && savedGameState.isRunning === false && savedGameState.isPaused === true) {
|
|
// Show auto-resume prompt
|
|
setTimeout(() => {
|
|
if (confirm('You have a paused game session. Would you like to resume where you left off?')) {
|
|
this.loadAutoSavedGame(savedGameState);
|
|
} else {
|
|
// Clear the auto-save if user declines
|
|
localStorage.removeItem('autoSaveGameState');
|
|
}
|
|
}, 500); // Small delay to ensure UI is ready
|
|
}
|
|
} catch (error) {
|
|
console.warn('Auto-resume check failed:', this.formatErrorMessage('auto-resume', error));
|
|
// Clear potentially corrupted auto-save data
|
|
localStorage.removeItem('autoSaveGameState');
|
|
}
|
|
}
|
|
|
|
autoSaveGameState() {
|
|
// Save current game state for auto-resume
|
|
if (this.gameState.isRunning || this.gameState.isPaused) {
|
|
const saveState = {
|
|
...this.gameState,
|
|
currentTask: this.gameState.currentTask,
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
this.dataManager.set('autoSaveGameState', saveState);
|
|
}
|
|
}
|
|
|
|
loadAutoSavedGame(savedState) {
|
|
// Restore the saved game state
|
|
this.gameState = {
|
|
...this.gameState,
|
|
...savedState,
|
|
isRunning: false, // Will be set to true when resumed
|
|
isPaused: true
|
|
};
|
|
|
|
// Update UI to show the paused game
|
|
this.showScreen('game-screen');
|
|
this.updateDisplay();
|
|
|
|
// Show the resume notification
|
|
this.showNotification('Game session restored! Click Resume to continue.', 'success');
|
|
}
|
|
|
|
// Loading indicator methods
|
|
showButtonLoading(buttonId) {
|
|
const button = document.getElementById(buttonId);
|
|
if (button) {
|
|
button.disabled = true;
|
|
const textSpan = button.querySelector('.btn-text');
|
|
const loadingSpan = button.querySelector('.btn-loading');
|
|
if (textSpan && loadingSpan) {
|
|
textSpan.style.display = 'none';
|
|
loadingSpan.style.display = 'inline';
|
|
}
|
|
}
|
|
}
|
|
|
|
hideButtonLoading(buttonId) {
|
|
const button = document.getElementById(buttonId);
|
|
if (button) {
|
|
button.disabled = false;
|
|
const textSpan = button.querySelector('.btn-text');
|
|
const loadingSpan = button.querySelector('.btn-loading');
|
|
if (textSpan && loadingSpan) {
|
|
textSpan.style.display = 'inline';
|
|
loadingSpan.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
toggleDifficultyDropdown() {
|
|
const taskType = document.getElementById('new-task-type').value;
|
|
const difficultyGroup = document.getElementById('difficulty-input-group');
|
|
|
|
if (taskType === 'main') {
|
|
difficultyGroup.style.display = 'block';
|
|
} else {
|
|
difficultyGroup.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Task Management Methods
|
|
showTaskManagement() {
|
|
this.showScreen('task-management-screen');
|
|
this.populateTaskLists();
|
|
this.toggleDifficultyDropdown(); // Set initial visibility
|
|
}
|
|
|
|
showTaskTab(tabType) {
|
|
// Update tab buttons
|
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
document.getElementById(`${tabType}-tasks-tab`).classList.add('active');
|
|
|
|
// Update task lists
|
|
document.querySelectorAll('.task-list').forEach(list => list.classList.remove('active'));
|
|
document.getElementById(`${tabType}-tasks-list`).classList.add('active');
|
|
}
|
|
|
|
populateTaskLists() {
|
|
this.populateTaskList('main', gameData.mainTasks);
|
|
this.populateTaskList('consequence', gameData.consequenceTasks);
|
|
}
|
|
|
|
populateTaskList(type, tasks) {
|
|
const listElement = document.getElementById(`${type}-tasks-list`);
|
|
|
|
if (tasks.length === 0) {
|
|
listElement.innerHTML = '<div class="empty-list">No tasks added yet. Add some tasks to get started!</div>';
|
|
return;
|
|
}
|
|
|
|
listElement.innerHTML = tasks.map((task, index) => {
|
|
let difficultyDisplay = '';
|
|
if (type === 'main' && task.difficulty) {
|
|
const emoji = this.getDifficultyEmoji(task.difficulty);
|
|
const points = this.getPointsForDifficulty(task.difficulty);
|
|
difficultyDisplay = `<div class="task-difficulty-display">${emoji} ${task.difficulty} (${points} ${points === 1 ? 'point' : 'points'})</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="task-item" data-type="${type}" data-index="${index}">
|
|
<div class="task-text-display">${task.text}</div>
|
|
${difficultyDisplay}
|
|
<div class="task-actions">
|
|
<button class="btn btn-info btn-small" onclick="game.editTask('${type}', ${index})">Edit</button>
|
|
<button class="btn btn-danger btn-small" onclick="game.deleteTask('${type}', ${index})">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
addNewTask() {
|
|
const taskText = document.getElementById('new-task-text').value.trim();
|
|
const taskType = document.getElementById('new-task-type').value;
|
|
const taskDifficulty = document.getElementById('new-task-difficulty').value;
|
|
|
|
if (!taskText) {
|
|
alert('Please enter a task description!');
|
|
return;
|
|
}
|
|
|
|
// Create new task object
|
|
const newTask = {
|
|
id: Date.now(), // Use timestamp as unique ID
|
|
text: taskText
|
|
};
|
|
|
|
// Add difficulty for main tasks only
|
|
if (taskType === 'main') {
|
|
newTask.difficulty = taskDifficulty;
|
|
}
|
|
|
|
// Add to appropriate array
|
|
if (taskType === 'main') {
|
|
gameData.mainTasks.push(newTask);
|
|
localStorage.setItem('customMainTasks', JSON.stringify(gameData.mainTasks));
|
|
} else {
|
|
gameData.consequenceTasks.push(newTask);
|
|
localStorage.setItem('customConsequenceTasks', JSON.stringify(gameData.consequenceTasks));
|
|
}
|
|
|
|
// Clear input and refresh list
|
|
document.getElementById('new-task-text').value = '';
|
|
document.getElementById('new-task-difficulty').value = 'Medium';
|
|
this.populateTaskLists();
|
|
|
|
// Switch to the appropriate tab
|
|
this.showTaskTab(taskType === 'main' ? 'main' : 'consequence');
|
|
|
|
console.log(`Added new ${taskType} task: ${taskText}`);
|
|
}
|
|
|
|
editTask(type, index) {
|
|
const tasks = type === 'main' ? gameData.mainTasks : gameData.consequenceTasks;
|
|
const task = tasks[index];
|
|
|
|
const newText = prompt(`Edit ${type} task:`, task.text);
|
|
if (newText !== null && newText.trim() !== '') {
|
|
tasks[index].text = newText.trim();
|
|
|
|
// Save to localStorage
|
|
const storageKey = type === 'main' ? 'customMainTasks' : 'customConsequenceTasks';
|
|
localStorage.setItem(storageKey, JSON.stringify(tasks));
|
|
|
|
// Refresh display
|
|
this.populateTaskLists();
|
|
console.log(`Edited ${type} task: ${newText}`);
|
|
}
|
|
}
|
|
|
|
deleteTask(type, index) {
|
|
const tasks = type === 'main' ? gameData.mainTasks : gameData.consequenceTasks;
|
|
const task = tasks[index];
|
|
|
|
if (confirm(`Are you sure you want to delete this ${type} task?\n\n"${task.text}"`)) {
|
|
tasks.splice(index, 1);
|
|
|
|
// Save to localStorage
|
|
const storageKey = type === 'main' ? 'customMainTasks' : 'customConsequenceTasks';
|
|
localStorage.setItem(storageKey, JSON.stringify(tasks));
|
|
|
|
// Refresh display
|
|
this.populateTaskLists();
|
|
console.log(`Deleted ${type} task: ${task.text}`);
|
|
}
|
|
}
|
|
|
|
resetToDefaultTasks() {
|
|
if (confirm('This will reset all tasks to the original defaults. Are you sure?')) {
|
|
// Clear localStorage
|
|
localStorage.removeItem('customMainTasks');
|
|
localStorage.removeItem('customConsequenceTasks');
|
|
|
|
// Reload the page to get fresh default tasks
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
changeTheme(themeName) {
|
|
// Remove all existing theme classes
|
|
const themeClasses = ['theme-ocean', 'theme-sunset', 'theme-forest', 'theme-midnight', 'theme-pastel', 'theme-neon', 'theme-autumn', 'theme-monochrome'];
|
|
document.body.classList.remove(...themeClasses);
|
|
|
|
// Add new theme class (ocean is default, no class needed)
|
|
if (themeName !== 'ocean') {
|
|
document.body.classList.add(`theme-${themeName}`);
|
|
}
|
|
|
|
// Save theme preference
|
|
localStorage.setItem('gameTheme', themeName);
|
|
console.log(`Theme changed to: ${themeName}`);
|
|
}
|
|
|
|
loadSavedTheme() {
|
|
const savedTheme = localStorage.getItem('gameTheme') || 'ocean';
|
|
document.getElementById('theme-dropdown').value = savedTheme;
|
|
this.changeTheme(savedTheme);
|
|
}
|
|
|
|
showScreen(screenId) {
|
|
console.log(`showScreen(${screenId}) called`);
|
|
// Hide all screens
|
|
document.querySelectorAll('.screen').forEach(screen => {
|
|
screen.classList.remove('active');
|
|
console.log(`Removed active class from screen:`, screen.id);
|
|
});
|
|
|
|
// Show target screen
|
|
const targetScreen = document.getElementById(screenId);
|
|
if (targetScreen) {
|
|
targetScreen.classList.add('active');
|
|
console.log(`Added active class to screen: ${screenId}`);
|
|
} else {
|
|
console.error(`Screen element not found: ${screenId}`);
|
|
}
|
|
|
|
// Update start screen status when showing start screen
|
|
if (screenId === 'start-screen') {
|
|
this.updateStartScreenStatus();
|
|
}
|
|
}
|
|
|
|
updateStartScreenStatus() {
|
|
const statusDiv = document.getElementById('start-screen-status');
|
|
if (!statusDiv) return;
|
|
|
|
const totalImages = gameData.discoveredTaskImages.length + gameData.discoveredConsequenceImages.length;
|
|
const customImages = this.dataManager.get('customImages') || [];
|
|
const totalAvailable = totalImages + customImages.length;
|
|
|
|
if (totalAvailable === 0) {
|
|
statusDiv.innerHTML = `
|
|
<div class="status-warning">
|
|
⚠️ No images found!
|
|
<br>Go to "Manage Images" to upload images or scan directories.
|
|
</div>
|
|
`;
|
|
statusDiv.style.display = 'block';
|
|
} else {
|
|
statusDiv.innerHTML = `
|
|
<div class="status-success">
|
|
✅ Ready to play! Found ${totalAvailable} images
|
|
(${totalImages} from directories, ${customImages.length} uploaded)
|
|
</div>
|
|
`;
|
|
statusDiv.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
toggleMusic() {
|
|
this.musicManager.toggle();
|
|
}
|
|
|
|
toggleLoop() {
|
|
this.musicManager.toggleLoopMode();
|
|
}
|
|
|
|
toggleShuffle() {
|
|
this.musicManager.toggleShuffleMode();
|
|
}
|
|
|
|
changeTrack(trackIndex) {
|
|
this.musicManager.changeTrack(trackIndex);
|
|
}
|
|
|
|
changeVolume(volume) {
|
|
this.musicManager.setVolume(volume);
|
|
}
|
|
|
|
// Data Management Methods
|
|
exportData() {
|
|
try {
|
|
this.showButtonLoading('export-btn');
|
|
|
|
// Small delay to show loading indicator
|
|
setTimeout(() => {
|
|
try {
|
|
this.dataManager.exportData(true);
|
|
this.showNotification('💾 Save file exported successfully!', 'success');
|
|
} catch (error) {
|
|
this.showNotification('❌ ' + this.formatErrorMessage('export', error), 'error');
|
|
} finally {
|
|
this.hideButtonLoading('export-btn');
|
|
}
|
|
}, 300);
|
|
} catch (error) {
|
|
this.showNotification('❌ ' + this.formatErrorMessage('export', error), 'error');
|
|
this.hideButtonLoading('export-btn');
|
|
}
|
|
}
|
|
|
|
importData() {
|
|
// Trigger the file input
|
|
document.getElementById('import-file').click();
|
|
}
|
|
|
|
async handleFileImport(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
this.showButtonLoading('import-btn');
|
|
|
|
try {
|
|
await this.dataManager.importData(file);
|
|
this.showNotification('📁 Save file imported successfully!', 'success');
|
|
|
|
// Refresh UI to show new data
|
|
this.musicManager.volume = this.dataManager.getSetting('music.volume') || 30;
|
|
this.musicManager.currentTrackIndex = this.dataManager.getSetting('music.currentTrack') || 0;
|
|
this.musicManager.loopMode = this.dataManager.getSetting('music.loopMode') || 0;
|
|
this.musicManager.shuffleMode = this.dataManager.getSetting('music.shuffle') || false;
|
|
this.musicManager.initializeVolumeUI();
|
|
|
|
// Update theme
|
|
const theme = this.dataManager.getSetting('theme') || 'ocean';
|
|
this.changeTheme(theme);
|
|
|
|
} catch (error) {
|
|
this.showNotification('❌ ' + this.formatErrorMessage('import', error), 'error');
|
|
} finally {
|
|
this.hideButtonLoading('import-btn');
|
|
}
|
|
|
|
// Clear the file input
|
|
event.target.value = '';
|
|
}
|
|
|
|
showStats() {
|
|
const stats = this.dataManager.getStats();
|
|
|
|
// Update stat displays
|
|
document.getElementById('stat-games').textContent = stats.totalGamesPlayed;
|
|
document.getElementById('stat-completed').textContent = stats.totalTasksCompleted;
|
|
document.getElementById('stat-score').textContent = stats.bestScore;
|
|
document.getElementById('stat-streak').textContent = stats.currentStreak;
|
|
document.getElementById('stat-rate').textContent = stats.completionPercentage + '%';
|
|
document.getElementById('stat-hours').textContent = stats.hoursPlayed;
|
|
|
|
document.getElementById('stats-modal').style.display = 'block';
|
|
}
|
|
|
|
hideStats() {
|
|
document.getElementById('stats-modal').style.display = 'none';
|
|
}
|
|
|
|
showHelp() {
|
|
document.getElementById('help-modal').style.display = 'block';
|
|
}
|
|
|
|
hideHelp() {
|
|
document.getElementById('help-modal').style.display = 'none';
|
|
}
|
|
|
|
toggleHelp() {
|
|
const helpModal = document.getElementById('help-modal');
|
|
if (helpModal.style.display === 'none' || helpModal.style.display === '') {
|
|
this.showHelp();
|
|
} else {
|
|
this.hideHelp();
|
|
}
|
|
}
|
|
|
|
// Image Management Methods
|
|
showImageManagement() {
|
|
this.showScreen('image-management-screen');
|
|
this.setupImageManagementEventListeners();
|
|
|
|
// Wait for image discovery to complete before loading gallery
|
|
if (!this.imageDiscoveryComplete) {
|
|
const gallery = document.getElementById('image-gallery');
|
|
gallery.innerHTML = '<div class="loading">Discovering images...</div>';
|
|
|
|
// Wait and try again
|
|
setTimeout(() => {
|
|
if (this.imageDiscoveryComplete) {
|
|
this.loadImageGallery();
|
|
} else {
|
|
gallery.innerHTML = '<div class="loading">Still discovering images... Please wait</div>';
|
|
setTimeout(() => this.loadImageGallery(), 1000);
|
|
}
|
|
}, 500);
|
|
} else {
|
|
this.loadImageGallery();
|
|
}
|
|
}
|
|
|
|
switchImageTab(tabType) {
|
|
// Update tab buttons
|
|
const taskTab = document.getElementById('task-images-tab');
|
|
const consequenceTab = document.getElementById('consequence-images-tab');
|
|
const taskGallery = document.getElementById('task-images-gallery');
|
|
const consequenceGallery = document.getElementById('consequence-images-gallery');
|
|
|
|
if (tabType === 'task') {
|
|
taskTab.classList.add('active');
|
|
consequenceTab.classList.remove('active');
|
|
taskGallery.classList.add('active');
|
|
consequenceGallery.classList.remove('active');
|
|
} else {
|
|
taskTab.classList.remove('active');
|
|
consequenceTab.classList.add('active');
|
|
taskGallery.classList.remove('active');
|
|
consequenceGallery.classList.add('active');
|
|
}
|
|
|
|
// Update gallery controls to work with current tab
|
|
this.updateImageGalleryControls(tabType);
|
|
}
|
|
|
|
updateImageGalleryControls(activeTab) {
|
|
// Update the select/deselect/delete buttons to work with the active tab
|
|
const selectAllBtn = document.getElementById('select-all-images-btn');
|
|
const deselectAllBtn = document.getElementById('deselect-all-images-btn');
|
|
const deleteBtn = document.getElementById('delete-selected-btn');
|
|
|
|
if (selectAllBtn) {
|
|
const newSelectAllBtn = selectAllBtn.cloneNode(true);
|
|
selectAllBtn.parentNode.replaceChild(newSelectAllBtn, selectAllBtn);
|
|
newSelectAllBtn.addEventListener('click', () => this.selectAllImages(activeTab));
|
|
}
|
|
|
|
if (deselectAllBtn) {
|
|
const newDeselectAllBtn = deselectAllBtn.cloneNode(true);
|
|
deselectAllBtn.parentNode.replaceChild(newDeselectAllBtn, deselectAllBtn);
|
|
newDeselectAllBtn.addEventListener('click', () => this.deselectAllImages(activeTab));
|
|
}
|
|
|
|
if (deleteBtn) {
|
|
const newDeleteBtn = deleteBtn.cloneNode(true);
|
|
deleteBtn.parentNode.replaceChild(newDeleteBtn, deleteBtn);
|
|
newDeleteBtn.addEventListener('click', () => this.deleteSelectedImages(activeTab));
|
|
}
|
|
}
|
|
|
|
setupImageManagementEventListeners() {
|
|
// Remove any existing listeners to prevent duplicates
|
|
const backBtn = document.getElementById('back-to-start-from-images-btn');
|
|
const uploadBtn = document.getElementById('upload-images-btn');
|
|
const uploadInput = document.getElementById('image-upload-input');
|
|
const storageInfoBtn = document.getElementById('storage-info-btn');
|
|
const selectAllBtn = document.getElementById('select-all-images-btn');
|
|
const deselectAllBtn = document.getElementById('deselect-all-images-btn');
|
|
const deleteBtn = document.getElementById('delete-selected-btn');
|
|
const taskImagesTab = document.getElementById('task-images-tab');
|
|
const consequenceImagesTab = document.getElementById('consequence-images-tab');
|
|
|
|
// Desktop-specific buttons
|
|
const importTaskBtn = document.getElementById('import-task-images-btn');
|
|
const importConsequenceBtn = document.getElementById('import-consequence-images-btn');
|
|
|
|
// Clone and replace to remove existing listeners
|
|
if (backBtn) {
|
|
const newBackBtn = backBtn.cloneNode(true);
|
|
backBtn.parentNode.replaceChild(newBackBtn, backBtn);
|
|
newBackBtn.addEventListener('click', () => this.showScreen('start-screen'));
|
|
}
|
|
|
|
// Desktop import buttons
|
|
if (importTaskBtn) {
|
|
const newImportTaskBtn = importTaskBtn.cloneNode(true);
|
|
importTaskBtn.parentNode.replaceChild(newImportTaskBtn, importTaskBtn);
|
|
newImportTaskBtn.addEventListener('click', async () => {
|
|
if (this.fileManager) {
|
|
await this.fileManager.selectAndImportImages('task');
|
|
this.loadImageGallery(); // Refresh the gallery to show new images
|
|
} else {
|
|
this.showNotification('Desktop file manager not available', 'warning');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (importConsequenceBtn) {
|
|
const newImportConsequenceBtn = importConsequenceBtn.cloneNode(true);
|
|
importConsequenceBtn.parentNode.replaceChild(newImportConsequenceBtn, importConsequenceBtn);
|
|
newImportConsequenceBtn.addEventListener('click', async () => {
|
|
if (this.fileManager) {
|
|
await this.fileManager.selectAndImportImages('consequence');
|
|
this.loadImageGallery(); // Refresh the gallery to show new images
|
|
} else {
|
|
this.showNotification('Desktop file manager not available', 'warning');
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// Original web upload button (fallback)
|
|
if (uploadBtn) {
|
|
const newUploadBtn = uploadBtn.cloneNode(true);
|
|
uploadBtn.parentNode.replaceChild(newUploadBtn, uploadBtn);
|
|
newUploadBtn.addEventListener('click', () => this.uploadImages());
|
|
}
|
|
|
|
if (storageInfoBtn) {
|
|
const newStorageInfoBtn = storageInfoBtn.cloneNode(true);
|
|
storageInfoBtn.parentNode.replaceChild(newStorageInfoBtn, storageInfoBtn);
|
|
newStorageInfoBtn.addEventListener('click', () => this.showStorageInfo());
|
|
}
|
|
|
|
if (taskImagesTab) {
|
|
const newTaskImagesTab = taskImagesTab.cloneNode(true);
|
|
taskImagesTab.parentNode.replaceChild(newTaskImagesTab, taskImagesTab);
|
|
newTaskImagesTab.addEventListener('click', () => this.switchImageTab('task'));
|
|
}
|
|
|
|
if (consequenceImagesTab) {
|
|
const newConsequenceImagesTab = consequenceImagesTab.cloneNode(true);
|
|
consequenceImagesTab.parentNode.replaceChild(newConsequenceImagesTab, consequenceImagesTab);
|
|
newConsequenceImagesTab.addEventListener('click', () => this.switchImageTab('consequence'));
|
|
}
|
|
|
|
if (uploadInput) {
|
|
const newUploadInput = uploadInput.cloneNode(true);
|
|
uploadInput.parentNode.replaceChild(newUploadInput, uploadInput);
|
|
newUploadInput.addEventListener('change', (e) => this.handleImageUpload(e));
|
|
}
|
|
|
|
if (selectAllBtn) {
|
|
const newSelectAllBtn = selectAllBtn.cloneNode(true);
|
|
selectAllBtn.parentNode.replaceChild(newSelectAllBtn, selectAllBtn);
|
|
newSelectAllBtn.addEventListener('click', () => this.selectAllImages());
|
|
}
|
|
|
|
if (deselectAllBtn) {
|
|
const newDeselectAllBtn = deselectAllBtn.cloneNode(true);
|
|
deselectAllBtn.parentNode.replaceChild(newDeselectAllBtn, deselectAllBtn);
|
|
newDeselectAllBtn.addEventListener('click', () => this.deselectAllImages());
|
|
}
|
|
|
|
if (deleteBtn) {
|
|
const newDeleteBtn = deleteBtn.cloneNode(true);
|
|
deleteBtn.parentNode.replaceChild(newDeleteBtn, deleteBtn);
|
|
newDeleteBtn.addEventListener('click', () => this.deleteSelectedImages());
|
|
}
|
|
}
|
|
|
|
loadImageGallery() {
|
|
// Load both task and consequence image galleries
|
|
this.cleanupInvalidImages(); // Clean up invalid images first
|
|
this.loadTaskImages();
|
|
this.loadConsequenceImages();
|
|
this.updateImageCounts();
|
|
|
|
// Initialize with task tab active and update controls
|
|
this.updateImageGalleryControls('task');
|
|
}
|
|
|
|
loadTaskImages() {
|
|
const gallery = document.getElementById('task-images-gallery');
|
|
gallery.innerHTML = '<div class="loading">Loading task images...</div>';
|
|
|
|
// Get task images
|
|
const taskImages = gameData.discoveredTaskImages || [];
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
const disabledImages = this.dataManager.get('disabledImages') || [];
|
|
|
|
// Get custom task images (handle both old array format and new object format)
|
|
let customTaskImages = [];
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - treat all as task images for backward compatibility
|
|
customTaskImages = customImages;
|
|
} else {
|
|
// New format - get task images specifically
|
|
customTaskImages = customImages.task || [];
|
|
}
|
|
|
|
const allTaskImages = [...taskImages, ...customTaskImages];
|
|
|
|
if (allTaskImages.length === 0) {
|
|
gallery.innerHTML = '<div class="no-images">No task images found. Upload or scan for task images!</div>';
|
|
return;
|
|
}
|
|
|
|
gallery.innerHTML = '';
|
|
this.renderImageItems(gallery, allTaskImages, disabledImages, customTaskImages);
|
|
}
|
|
|
|
loadConsequenceImages() {
|
|
const gallery = document.getElementById('consequence-images-gallery');
|
|
gallery.innerHTML = '<div class="loading">Loading consequence images...</div>';
|
|
|
|
// Get consequence images
|
|
const consequenceImages = gameData.discoveredConsequenceImages || [];
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
const disabledImages = this.dataManager.get('disabledImages') || [];
|
|
|
|
// Get custom consequence images (new format only)
|
|
let customConsequenceImages = [];
|
|
if (!Array.isArray(customImages)) {
|
|
customConsequenceImages = customImages.consequence || [];
|
|
}
|
|
|
|
const allConsequenceImages = [...consequenceImages, ...customConsequenceImages];
|
|
|
|
if (allConsequenceImages.length === 0) {
|
|
gallery.innerHTML = '<div class="no-images">No consequence images found. Upload or scan for consequence images!</div>';
|
|
return;
|
|
}
|
|
|
|
gallery.innerHTML = '';
|
|
this.renderImageItems(gallery, allConsequenceImages, disabledImages, customConsequenceImages);
|
|
}
|
|
|
|
renderImageItems(gallery, images, disabledImages, customImages) {
|
|
images.forEach((imageData, index) => {
|
|
const imageItem = document.createElement('div');
|
|
imageItem.className = 'image-item';
|
|
|
|
// Handle both old base64 format and new cached metadata format
|
|
let imagePath, displayName, imageSrc;
|
|
|
|
if (typeof imageData === 'string') {
|
|
// Old base64 format or regular file path
|
|
imagePath = imageData;
|
|
imageSrc = imageData;
|
|
displayName = this.getImageDisplayName(imageData);
|
|
} else {
|
|
// New cached metadata format
|
|
imagePath = imageData.cachedPath || imageData.originalName;
|
|
|
|
// Use dataUrl if available, otherwise show placeholder
|
|
if (imageData.dataUrl) {
|
|
imageSrc = imageData.dataUrl;
|
|
} else {
|
|
// Legacy cached image without dataUrl - show placeholder
|
|
imageSrc = this.createPlaceholderImage('Missing Data');
|
|
console.warn('Image missing dataUrl:', imageData.originalName);
|
|
}
|
|
|
|
displayName = imageData.originalName || 'Cached Image';
|
|
}
|
|
|
|
imageItem.dataset.imagePath = imagePath;
|
|
|
|
const isDisabled = disabledImages.includes(imagePath);
|
|
|
|
imageItem.innerHTML = `
|
|
<img src="${imageSrc}" alt="Image" class="image-preview" title="${displayName}" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGVlMmU2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzZjNzU3ZCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkZhaWxlZCB0byBsb2FkPC90ZXh0Pjwvc3ZnPg=='">
|
|
<div class="image-info">
|
|
<div class="image-controls">
|
|
<label>
|
|
<input type="checkbox" class="image-checkbox" ${isDisabled ? '' : 'checked'}>
|
|
Enable
|
|
</label>
|
|
<span class="image-status ${isDisabled ? 'image-disabled' : 'image-enabled'}">
|
|
${isDisabled ? 'Disabled' : 'Enabled'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listener for enable/disable
|
|
const checkbox = imageItem.querySelector('.image-checkbox');
|
|
checkbox.addEventListener('change', (e) => this.toggleImageEnabled(imagePath, e.target.checked));
|
|
|
|
// Add event listener for individual image selection (click to select/deselect)
|
|
imageItem.addEventListener('click', (e) => {
|
|
// Don't toggle selection if clicking on the checkbox
|
|
if (e.target.type !== 'checkbox') {
|
|
this.toggleImageSelection(imageItem);
|
|
}
|
|
});
|
|
|
|
gallery.appendChild(imageItem);
|
|
});
|
|
}
|
|
|
|
updateImageCounts() {
|
|
const imageCount = document.querySelector('.image-count');
|
|
const taskImages = gameData.discoveredTaskImages || [];
|
|
const consequenceImages = gameData.discoveredConsequenceImages || [];
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
const disabledImages = this.dataManager.get('disabledImages') || [];
|
|
|
|
// Handle both old array format and new object format
|
|
let customTaskCount = 0;
|
|
let customConsequenceCount = 0;
|
|
let totalCustomImages = 0;
|
|
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - treat all as task images
|
|
customTaskCount = customImages.length;
|
|
totalCustomImages = customImages.length;
|
|
} else {
|
|
// New format
|
|
customTaskCount = (customImages.task || []).length;
|
|
customConsequenceCount = (customImages.consequence || []).length;
|
|
totalCustomImages = customTaskCount + customConsequenceCount;
|
|
}
|
|
|
|
const totalTaskImages = taskImages.length + customTaskCount;
|
|
const totalConsequenceImages = consequenceImages.length + customConsequenceCount;
|
|
const totalImages = totalTaskImages + totalConsequenceImages;
|
|
const enabledImages = totalImages - disabledImages.length;
|
|
|
|
if (imageCount) {
|
|
imageCount.textContent = `${totalImages} total images (${totalTaskImages} task, ${totalConsequenceImages} consequence, ${enabledImages} enabled)`;
|
|
}
|
|
}
|
|
|
|
getAllImages() {
|
|
// Return the current images from the game's discovery system
|
|
const taskImages = gameData.discoveredTaskImages || [];
|
|
const consequenceImages = gameData.discoveredConsequenceImages || [];
|
|
|
|
console.log('getAllImages - taskImages:', taskImages);
|
|
console.log('getAllImages - consequenceImages:', consequenceImages);
|
|
console.log('getAllImages - imageDiscoveryComplete:', this.imageDiscoveryComplete);
|
|
|
|
return [...taskImages, ...consequenceImages];
|
|
}
|
|
|
|
getImageDisplayName(imagePath) {
|
|
// Extract filename from path
|
|
const parts = imagePath.split('/');
|
|
return parts[parts.length - 1];
|
|
}
|
|
|
|
toggleImageEnabled(imagePath, enabled) {
|
|
let disabledImages = this.dataManager.get('disabledImages') || [];
|
|
|
|
if (enabled) {
|
|
disabledImages = disabledImages.filter(img => img !== imagePath);
|
|
} else {
|
|
if (!disabledImages.includes(imagePath)) {
|
|
disabledImages.push(imagePath);
|
|
}
|
|
}
|
|
|
|
this.dataManager.set('disabledImages', disabledImages);
|
|
|
|
// Update the status display
|
|
const imageItem = document.querySelector(`[data-image-path="${imagePath}"]`);
|
|
if (imageItem) {
|
|
const statusElement = imageItem.querySelector('.image-status');
|
|
statusElement.className = `image-status ${enabled ? 'image-enabled' : 'image-disabled'}`;
|
|
statusElement.textContent = enabled ? 'Enabled' : 'Disabled';
|
|
}
|
|
|
|
// Update count
|
|
this.updateImageCount();
|
|
|
|
this.showNotification(`Image ${enabled ? 'enabled' : 'disabled'} successfully!`, 'success');
|
|
}
|
|
|
|
toggleImageSelection(imageItem) {
|
|
imageItem.classList.toggle('selected');
|
|
}
|
|
|
|
uploadImages() {
|
|
document.getElementById('image-upload-input').click();
|
|
}
|
|
|
|
async handleImageUpload(event) {
|
|
const files = Array.from(event.target.files);
|
|
if (files.length === 0) return;
|
|
|
|
// Determine which tab is active to know where to save images
|
|
const activeTab = this.getActiveImageTab();
|
|
|
|
this.showNotification(`Processing ${files.length} images for ${activeTab} category...`, 'info');
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
const maxImages = 50; // Reasonable limit to prevent storage overflow
|
|
|
|
// Check current image count
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
const currentTaskCount = Array.isArray(customImages) ? customImages.length : (customImages.task || []).length;
|
|
const currentConsequenceCount = Array.isArray(customImages) ? 0 : (customImages.consequence || []).length;
|
|
const totalCurrentImages = currentTaskCount + currentConsequenceCount;
|
|
|
|
if (totalCurrentImages >= maxImages) {
|
|
this.showNotification(`Image limit reached (${maxImages} max). Please delete some images first.`, 'error');
|
|
return;
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (successCount + totalCurrentImages >= maxImages) {
|
|
this.showNotification(`Stopped at image limit (${maxImages} max). ${successCount} uploaded, ${files.length - successCount} skipped.`, 'warning');
|
|
break;
|
|
}
|
|
|
|
try {
|
|
const imageData = await this.processUploadedImage(file, activeTab);
|
|
if (imageData) {
|
|
// Try to save to localStorage with error handling
|
|
if (await this.saveImageWithQuotaCheck(imageData, activeTab)) {
|
|
successCount++;
|
|
} else {
|
|
failedCount++;
|
|
console.warn('Storage quota exceeded for image:', file.name);
|
|
break; // Stop processing if quota exceeded
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to process image:', file.name, error);
|
|
failedCount++;
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
this.showNotification(`${successCount} ${activeTab} image(s) uploaded successfully!${failedCount > 0 ? ` (${failedCount} failed due to storage limits)` : ''}`, successCount > failedCount ? 'success' : 'warning');
|
|
this.loadImageGallery(); // Refresh the gallery
|
|
this.cleanupInvalidImages(); // Clean up any images without dataUrl
|
|
} else {
|
|
this.showNotification(`Upload failed. ${failedCount > 0 ? 'Storage quota exceeded. Try deleting some images first.' : 'Please try again.'}`, 'error');
|
|
}
|
|
|
|
// Clear the input
|
|
event.target.value = '';
|
|
}
|
|
|
|
async saveImageWithQuotaCheck(imageData, activeTab) {
|
|
try {
|
|
let customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
|
|
// Ensure the structure exists
|
|
if (!customImages.task) customImages.task = [];
|
|
if (!customImages.consequence) customImages.consequence = [];
|
|
|
|
// Add to the appropriate category
|
|
customImages[activeTab].push(imageData);
|
|
|
|
// Try to save with quota checking
|
|
this.dataManager.set('customImages', customImages);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.name === 'QuotaExceededError') {
|
|
console.error('Storage quota exceeded:', error);
|
|
return false;
|
|
} else {
|
|
console.error('Error saving image:', error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
getActiveImageTab() {
|
|
const taskTab = document.getElementById('task-images-tab');
|
|
return taskTab && taskTab.classList.contains('active') ? 'task' : 'consequence';
|
|
}
|
|
|
|
cleanupInvalidImages() {
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
let hasChanges = false;
|
|
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - clean up and convert to new format
|
|
const validImages = customImages.filter(img => {
|
|
if (typeof img === 'string') {
|
|
return true; // Keep old base64 strings
|
|
} else {
|
|
return !!img.dataUrl; // Only keep metadata objects with dataUrl
|
|
}
|
|
});
|
|
|
|
if (validImages.length !== customImages.length) {
|
|
console.log(`Cleaned up ${customImages.length - validImages.length} invalid cached images`);
|
|
// Convert to new format - put all in task category for backward compatibility
|
|
this.dataManager.set('customImages', { task: validImages, consequence: [] });
|
|
hasChanges = true;
|
|
} else {
|
|
// Convert to new format without cleaning
|
|
this.dataManager.set('customImages', { task: customImages, consequence: [] });
|
|
hasChanges = true;
|
|
}
|
|
} else {
|
|
// New format - clean up both categories
|
|
const cleanCategory = (category) => {
|
|
const images = customImages[category] || [];
|
|
return images.filter(img => {
|
|
if (typeof img === 'string') {
|
|
return true; // Keep old base64 strings
|
|
} else {
|
|
return !!img.dataUrl; // Only keep metadata objects with dataUrl
|
|
}
|
|
});
|
|
};
|
|
|
|
const validTaskImages = cleanCategory('task');
|
|
const validConsequenceImages = cleanCategory('consequence');
|
|
|
|
const originalTaskCount = (customImages.task || []).length;
|
|
const originalConsequenceCount = (customImages.consequence || []).length;
|
|
|
|
if (validTaskImages.length !== originalTaskCount || validConsequenceImages.length !== originalConsequenceCount) {
|
|
const cleanedCount = (originalTaskCount + originalConsequenceCount) - (validTaskImages.length + validConsequenceImages.length);
|
|
console.log(`Cleaned up ${cleanedCount} invalid cached images`);
|
|
this.dataManager.set('customImages', { task: validTaskImages, consequence: validConsequenceImages });
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
return hasChanges;
|
|
}
|
|
|
|
async scanForNewImages() {
|
|
this.showNotification('Scanning directories for new images...', 'info');
|
|
|
|
try {
|
|
// Get the current embedded manifest
|
|
let manifest = this.getEmbeddedManifest();
|
|
|
|
// Force a fresh scan by clearing any cached manifest
|
|
this.dataManager.set('cachedManifest', null);
|
|
|
|
// Perform comprehensive scan for new images
|
|
console.log('Performing user-requested scan for new images...');
|
|
manifest = await this.updateManifestWithNewImages(manifest);
|
|
|
|
// Update discovered images with new manifest
|
|
gameData.discoveredTaskImages = await this.verifyImagesFromManifest(manifest.tasks, 'images/tasks/');
|
|
gameData.discoveredConsequenceImages = await this.verifyImagesFromManifest(manifest.consequences, 'images/consequences/');
|
|
|
|
// Check how many new images were found
|
|
const totalImages = gameData.discoveredTaskImages.length + gameData.discoveredConsequenceImages.length;
|
|
|
|
this.showNotification(`Scan complete! Found ${totalImages} total images.`, 'success');
|
|
this.loadImageGallery(); // Refresh the gallery to show any new images
|
|
|
|
} catch (error) {
|
|
console.error('Directory scan failed:', error);
|
|
this.showNotification('Directory scan failed. Please try again.', 'error');
|
|
}
|
|
}
|
|
|
|
clearImageCache() {
|
|
// Clear the cached manifest to force a fresh scan next time
|
|
this.dataManager.set('cachedManifest', null);
|
|
this.showNotification('Image cache cleared. Next scan will be fresh.', 'success');
|
|
}
|
|
|
|
showStorageInfo() {
|
|
try {
|
|
const isDesktop = window.electronAPI !== undefined;
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
|
|
let totalCustomImages = 0;
|
|
let taskCount = 0;
|
|
let consequenceCount = 0;
|
|
|
|
if (Array.isArray(customImages)) {
|
|
// Old format
|
|
totalCustomImages = customImages.length;
|
|
taskCount = customImages.length;
|
|
consequenceCount = 0;
|
|
} else {
|
|
// New format - count both categories
|
|
taskCount = (customImages.task || []).length;
|
|
consequenceCount = (customImages.consequence || []).length;
|
|
totalCustomImages = taskCount + consequenceCount;
|
|
}
|
|
|
|
let message;
|
|
|
|
if (isDesktop) {
|
|
message = `
|
|
📊 Desktop Application Storage:
|
|
|
|
🖼️ Image Library:
|
|
├─ Task Images: ${taskCount}
|
|
└─ Consequence Images: ${consequenceCount}
|
|
Total Images: ${totalCustomImages}
|
|
|
|
💾 Storage Type: Native File System
|
|
📁 Storage Location: images/ folder in app directory
|
|
💡 Storage Capacity: Unlimited (depends on available disk space)
|
|
|
|
✅ Desktop Benefits:
|
|
• No browser storage limitations
|
|
• Full-resolution image support
|
|
• Native file system performance
|
|
• Automatic directory organization
|
|
• Cross-platform file compatibility
|
|
|
|
🎯 Image Management:
|
|
• Import images using native file dialogs
|
|
• Images automatically organized by category
|
|
• No compression or size restrictions
|
|
• Direct file system access for best performance
|
|
|
|
${totalCustomImages === 0 ? '📷 No custom images imported yet. Use the Import buttons to add your images!' : ''}
|
|
`.trim();
|
|
} else {
|
|
// Original web version info
|
|
const storageData = localStorage.getItem(this.dataManager.storageKey);
|
|
const storageSize = storageData ? storageData.length : 0;
|
|
const storageMB = (storageSize / (1024 * 1024)).toFixed(2);
|
|
const maxMB = 6;
|
|
const usagePercent = ((storageSize / (maxMB * 1024 * 1024)) * 100).toFixed(1);
|
|
|
|
message = `
|
|
📊 Browser Storage Information:
|
|
|
|
LocalStorage Used: ${storageMB} MB / ~${maxMB} MB (${usagePercent}%)
|
|
Total Custom Images: ${totalCustomImages}
|
|
|
|
${usagePercent > 85 ? '⚠️ Storage getting full - consider deleting some images' :
|
|
usagePercent > 70 ? '⚡ Storage usage is moderate' :
|
|
'✅ Storage usage is healthy'}
|
|
|
|
💡 Browser Limitations:
|
|
- Limited to ~6MB total storage
|
|
- Image compression required
|
|
- 50 image limit to prevent storage issues
|
|
|
|
💡 Consider upgrading to the desktop version for unlimited storage!
|
|
`.trim();
|
|
}
|
|
|
|
alert(message);
|
|
} catch (error) {
|
|
this.showNotification('Failed to get storage info', 'error');
|
|
}
|
|
}
|
|
|
|
async processUploadedImage(file, category = 'task') {
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
// Validate file type
|
|
if (!file.type.startsWith('image/')) {
|
|
reject(new Error('Not an image file'));
|
|
return;
|
|
}
|
|
|
|
// Check file size before processing - 20MB limit
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
reject(new Error('Image too large. Please use images smaller than 20MB.'));
|
|
return;
|
|
}
|
|
|
|
console.log(`Processing image: ${file.name}, size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`);
|
|
|
|
// Generate unique filename for cached image
|
|
const timestamp = Date.now();
|
|
const fileExtension = file.name.split('.').pop().toLowerCase();
|
|
const cachedFileName = `cached_${timestamp}.${fileExtension}`;
|
|
const cachedImagePath = `images/cached/${cachedFileName}`;
|
|
|
|
// For web compatibility, we'll still compress but save as blob URL reference
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const img = new Image();
|
|
|
|
img.onload = async () => {
|
|
try {
|
|
// Calculate new dimensions (higher quality - 1600x1200)
|
|
let { width, height } = img;
|
|
const maxWidth = 1600;
|
|
const maxHeight = 1200;
|
|
|
|
console.log(`Original dimensions: ${width}x${height}`);
|
|
|
|
if (width > maxWidth || height > maxHeight) {
|
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
width = Math.floor(width * ratio);
|
|
height = Math.floor(height * ratio);
|
|
console.log(`Resized dimensions: ${width}x${height}`);
|
|
}
|
|
|
|
// Set canvas size and draw high-quality image
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Convert to blob with high quality (0.95 for minimal compression)
|
|
canvas.toBlob(async (blob) => {
|
|
try {
|
|
// Save the blob as file reference
|
|
const imageData = await this.saveBlobToCache(blob, cachedFileName, file.name);
|
|
const compressedSize = (blob.size / (1024 * 1024)).toFixed(2);
|
|
console.log(`Cached image size: ${compressedSize}MB at ${cachedImagePath}`);
|
|
|
|
resolve(imageData);
|
|
} catch (error) {
|
|
console.error('Failed to save to cache:', error);
|
|
reject(error);
|
|
}
|
|
}, 'image/jpeg', 0.95);
|
|
|
|
} catch (error) {
|
|
console.error('Image processing error:', error);
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
img.onerror = () => reject(new Error('Failed to load image'));
|
|
|
|
// Create object URL for the image
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
img.src = e.target.result;
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async saveBlobToCache(blob, cachedFileName, originalName) {
|
|
// Convert blob to base64 for reliable storage and display
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const imageMetadata = {
|
|
cachedFileName: cachedFileName,
|
|
originalName: originalName,
|
|
cachedPath: `images/cached/${cachedFileName}`,
|
|
dataUrl: reader.result, // Store the base64 data URL
|
|
size: blob.size,
|
|
type: blob.type,
|
|
timestamp: Date.now()
|
|
};
|
|
resolve(imageMetadata);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
selectAllImages(tabType = 'task') {
|
|
const galleryId = tabType === 'task' ? 'task-images-gallery' : 'consequence-images-gallery';
|
|
const gallery = document.getElementById(galleryId);
|
|
const imageItems = gallery.querySelectorAll('.image-item');
|
|
imageItems.forEach(imageItem => {
|
|
imageItem.classList.add('selected');
|
|
});
|
|
}
|
|
|
|
deselectAllImages(tabType = 'task') {
|
|
const galleryId = tabType === 'task' ? 'task-images-gallery' : 'consequence-images-gallery';
|
|
const gallery = document.getElementById(galleryId);
|
|
const imageItems = gallery.querySelectorAll('.image-item');
|
|
imageItems.forEach(imageItem => {
|
|
imageItem.classList.remove('selected');
|
|
});
|
|
}
|
|
|
|
async deleteSelectedImages(tabType = 'task') {
|
|
const galleryId = tabType === 'task' ? 'task-images-gallery' : 'consequence-images-gallery';
|
|
const gallery = document.getElementById(galleryId);
|
|
const selectedItems = gallery.querySelectorAll('.image-item.selected');
|
|
|
|
if (selectedItems.length === 0) {
|
|
this.showNotification('No images selected for deletion.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Are you sure you want to delete ${selectedItems.length} selected image(s)?`)) {
|
|
return;
|
|
}
|
|
|
|
let customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
let deletedCount = 0;
|
|
let fileDeletedCount = 0;
|
|
|
|
// Handle both old array format and new object format
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - convert to new format first
|
|
customImages = { task: customImages, consequence: [] };
|
|
}
|
|
|
|
// If we're in desktop mode, also delete the actual files
|
|
const isDesktop = this.fileManager && this.fileManager.isElectron;
|
|
|
|
for (const item of selectedItems) {
|
|
const imagePath = item.dataset.imagePath;
|
|
|
|
// Determine which category this image belongs to
|
|
let imageCategory = 'task'; // default
|
|
if (imagePath.includes('consequences') || imagePath.includes('consequence')) {
|
|
imageCategory = 'consequence';
|
|
} else if (imagePath.includes('tasks') || imagePath.includes('task')) {
|
|
imageCategory = 'task';
|
|
}
|
|
|
|
// Delete the actual file if in desktop mode
|
|
if (isDesktop) {
|
|
try {
|
|
const fileDeleted = await this.fileManager.deleteImage(imagePath, imageCategory);
|
|
if (fileDeleted) {
|
|
fileDeletedCount++;
|
|
deletedCount++; // File manager handles storage cleanup
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting file:', error);
|
|
}
|
|
} else {
|
|
// Web mode - only remove from storage
|
|
['task', 'consequence'].forEach(category => {
|
|
const initialLength = customImages[category].length;
|
|
customImages[category] = customImages[category].filter(img => {
|
|
if (typeof img === 'string') {
|
|
return img !== imagePath; // Old format: direct string comparison
|
|
} else {
|
|
// New format: compare against cachedPath or originalName
|
|
const imgPath = img.cachedPath || img.originalName;
|
|
return imgPath !== imagePath;
|
|
}
|
|
});
|
|
|
|
if (customImages[category].length < initialLength) {
|
|
deletedCount++;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update storage if not in desktop mode (desktop mode handles this in deleteImage)
|
|
if (!isDesktop && deletedCount > 0) {
|
|
this.dataManager.set('customImages', customImages);
|
|
}
|
|
|
|
// Clean up disabled image entries for deleted images
|
|
if (deletedCount > 0) {
|
|
let disabledImages = this.dataManager.get('disabledImages') || [];
|
|
const originalDisabledLength = disabledImages.length;
|
|
|
|
// Remove disabled entries for deleted images
|
|
selectedItems.forEach(item => {
|
|
const imagePath = item.dataset.imagePath;
|
|
disabledImages = disabledImages.filter(img => img !== imagePath);
|
|
});
|
|
|
|
if (disabledImages.length < originalDisabledLength) {
|
|
this.dataManager.set('disabledImages', disabledImages);
|
|
}
|
|
|
|
const message = isDesktop
|
|
? `${fileDeletedCount} image file(s) and references deleted successfully!`
|
|
: `${deletedCount} custom image(s) deleted successfully!`;
|
|
this.showNotification(message, 'success');
|
|
this.loadImageGallery(); // Refresh the gallery
|
|
} else {
|
|
this.showNotification('No custom images were selected. Default images cannot be deleted.', 'warning');
|
|
}
|
|
}
|
|
|
|
updateImageCount() {
|
|
const images = this.getAllImages();
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
const disabledImages = this.dataManager.get('disabledImages') || [];
|
|
|
|
// Handle both old and new customImages format
|
|
let customImagesArray = [];
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - flat array
|
|
customImagesArray = customImages;
|
|
} else {
|
|
// New format - object with task and consequence arrays
|
|
customImagesArray = [...(customImages.task || []), ...(customImages.consequence || [])];
|
|
}
|
|
|
|
const allImages = [...images, ...customImagesArray];
|
|
|
|
const imageCount = document.querySelector('.image-count');
|
|
if (imageCount) {
|
|
imageCount.textContent = `${allImages.length} images (${allImages.length - disabledImages.length} enabled)`;
|
|
}
|
|
}
|
|
|
|
resetStats() {
|
|
if (this.dataManager.resetStats()) {
|
|
this.showNotification('📊 Statistics reset successfully!', 'success');
|
|
this.hideStats();
|
|
}
|
|
}
|
|
|
|
exportStatsOnly() {
|
|
try {
|
|
this.showButtonLoading('export-stats-btn');
|
|
|
|
// Small delay to show loading indicator
|
|
setTimeout(() => {
|
|
try {
|
|
this.dataManager.exportData(false);
|
|
this.showNotification('📊 Statistics exported successfully!', 'success');
|
|
} catch (error) {
|
|
this.showNotification('❌ ' + this.formatErrorMessage('stats-export', error), 'error');
|
|
} finally {
|
|
this.hideButtonLoading('export-stats-btn');
|
|
}
|
|
}, 300);
|
|
} catch (error) {
|
|
this.showNotification('❌ ' + this.formatErrorMessage('stats-export', error), 'error');
|
|
this.hideButtonLoading('export-stats-btn');
|
|
}
|
|
}
|
|
|
|
// Better error message formatting
|
|
formatErrorMessage(operation, error) {
|
|
const baseMessages = {
|
|
'export': 'Failed to export save data',
|
|
'import': 'Failed to import save data',
|
|
'stats-export': 'Failed to export statistics',
|
|
'auto-resume': 'Failed to restore previous session'
|
|
};
|
|
|
|
const specificErrors = {
|
|
'QuotaExceededError': 'Not enough storage space available',
|
|
'SecurityError': 'File access blocked by browser security',
|
|
'TypeError': 'Invalid file format detected',
|
|
'SyntaxError': 'Corrupted save file data'
|
|
};
|
|
|
|
const baseMessage = baseMessages[operation] || 'Operation failed';
|
|
const specificMessage = specificErrors[error.name] || error.message;
|
|
|
|
return `${baseMessage}: ${specificMessage}`;
|
|
}
|
|
|
|
showNotification(message, type = 'info') {
|
|
// Create notification element if it doesn't exist
|
|
let notification = document.getElementById('notification');
|
|
if (!notification) {
|
|
notification = document.createElement('div');
|
|
notification.id = 'notification';
|
|
notification.className = 'notification';
|
|
document.body.appendChild(notification);
|
|
}
|
|
|
|
notification.textContent = message;
|
|
notification.className = `notification ${type} show`;
|
|
|
|
// Hide after 3 seconds
|
|
setTimeout(() => {
|
|
notification.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
startGame() {
|
|
console.log('startGame() called');
|
|
if (!this.imageDiscoveryComplete) {
|
|
console.log('Image discovery not complete, retrying in 500ms...');
|
|
// Wait a bit longer for image discovery
|
|
setTimeout(() => this.startGame(), 500);
|
|
return;
|
|
}
|
|
|
|
// Check if we have any images available
|
|
const totalImages = gameData.discoveredTaskImages.length + gameData.discoveredConsequenceImages.length;
|
|
const customImages = this.dataManager.get('customImages') || [];
|
|
|
|
if (totalImages === 0 && customImages.length === 0) {
|
|
// No images available - guide user to add images
|
|
this.showNotification('No images found! Please upload images or scan directories first.', 'error', 5000);
|
|
this.showScreen('image-management-screen');
|
|
return;
|
|
}
|
|
|
|
console.log('Starting game initialization...');
|
|
this.gameState.isRunning = true;
|
|
this.gameState.isPaused = false;
|
|
this.gameState.startTime = Date.now();
|
|
this.gameState.totalPausedTime = 0;
|
|
this.gameState.score = 0;
|
|
this.gameState.lastSkippedTask = null;
|
|
|
|
console.log('Recording game start in data manager...');
|
|
// Record game start in data manager
|
|
this.dataManager.recordGameStart();
|
|
|
|
console.log('Starting timer...');
|
|
this.startTimer();
|
|
console.log('Loading next task...');
|
|
this.loadNextTask();
|
|
console.log('Showing game screen...');
|
|
this.showScreen('game-screen');
|
|
console.log('Updating stats...');
|
|
this.updateStats();
|
|
console.log('startGame() completed successfully');
|
|
}
|
|
|
|
loadNextTask() {
|
|
if (this.gameState.isConsequenceTask) {
|
|
// Load a consequence task
|
|
this.loadConsequenceTask();
|
|
} else {
|
|
// Load a main task
|
|
this.loadMainTask();
|
|
}
|
|
|
|
this.displayCurrentTask();
|
|
}
|
|
|
|
loadMainTask() {
|
|
const availableTasks = gameData.mainTasks.filter(
|
|
task => !this.gameState.usedMainTasks.includes(task.id)
|
|
);
|
|
|
|
if (availableTasks.length === 0) {
|
|
// All main tasks completed - end game
|
|
this.endGame();
|
|
return;
|
|
}
|
|
|
|
// Select random task and random image from task pool
|
|
const randomIndex = Math.floor(Math.random() * availableTasks.length);
|
|
this.gameState.currentTask = {
|
|
...availableTasks[randomIndex],
|
|
image: this.getRandomImage(false) // false = main task image
|
|
};
|
|
this.gameState.isConsequenceTask = false;
|
|
}
|
|
|
|
loadConsequenceTask() {
|
|
const availableTasks = gameData.consequenceTasks.filter(
|
|
task => !this.gameState.usedConsequenceTasks.includes(task.id)
|
|
);
|
|
|
|
if (availableTasks.length === 0) {
|
|
// Reset consequence tasks if all used
|
|
this.gameState.usedConsequenceTasks = [];
|
|
}
|
|
|
|
const tasksToChooseFrom = availableTasks.length > 0 ? availableTasks : gameData.consequenceTasks;
|
|
const randomIndex = Math.floor(Math.random() * tasksToChooseFrom.length);
|
|
this.gameState.currentTask = {
|
|
...tasksToChooseFrom[randomIndex],
|
|
image: this.getRandomImage(true) // true = consequence task image
|
|
};
|
|
this.gameState.isConsequenceTask = true;
|
|
}
|
|
|
|
getRandomImage(isConsequence = false) {
|
|
let imagePool;
|
|
let imageType;
|
|
|
|
if (isConsequence) {
|
|
imagePool = gameData.discoveredConsequenceImages;
|
|
imageType = 'consequence';
|
|
} else {
|
|
imagePool = gameData.discoveredTaskImages;
|
|
imageType = 'task';
|
|
}
|
|
|
|
// Add custom images to the pool
|
|
const customImages = this.dataManager.get('customImages') || { task: [], consequence: [] };
|
|
|
|
// Handle both old array format and new object format
|
|
let customImagePool = [];
|
|
if (Array.isArray(customImages)) {
|
|
// Old format - treat all as task images
|
|
customImagePool = isConsequence ? [] : customImages;
|
|
} else {
|
|
// New format - get appropriate category
|
|
customImagePool = customImages[imageType] || [];
|
|
}
|
|
|
|
imagePool = [...imagePool, ...customImagePool];
|
|
|
|
// Filter out disabled images - need to handle both formats
|
|
const disabledImages = this.dataManager.get('disabledImages') || [];
|
|
imagePool = imagePool.filter(img => {
|
|
// Handle both string paths and metadata objects
|
|
const imagePath = typeof img === 'string' ? img : (img.cachedPath || img.originalName);
|
|
return !disabledImages.includes(imagePath);
|
|
});
|
|
|
|
if (imagePool.length === 0) {
|
|
console.log(`No enabled ${imageType} images found, using placeholder`);
|
|
return this.createPlaceholderImage(isConsequence ? 'Consequence Image' : 'Task Image');
|
|
}
|
|
|
|
const randomIndex = Math.floor(Math.random() * imagePool.length);
|
|
const selectedImage = imagePool[randomIndex];
|
|
|
|
// Convert to displayable format
|
|
const displayImage = this.getImageSrc(selectedImage);
|
|
console.log(`Selected ${imageType} image: ${typeof selectedImage === 'string' ? selectedImage : selectedImage.originalName}`);
|
|
return displayImage;
|
|
}
|
|
|
|
getImageSrc(imageData) {
|
|
// Handle both old base64/path format and new cached metadata format
|
|
if (typeof imageData === 'string') {
|
|
return imageData; // Old format: base64 or file path
|
|
} else {
|
|
return imageData.dataUrl || imageData.cachedPath; // New format: use data URL or cached path
|
|
}
|
|
}
|
|
|
|
displayCurrentTask() {
|
|
const taskText = document.getElementById('task-text');
|
|
const taskImage = document.getElementById('task-image');
|
|
const taskTypeIndicator = document.getElementById('task-type-indicator');
|
|
const taskDifficulty = document.getElementById('task-difficulty');
|
|
const taskPoints = document.getElementById('task-points');
|
|
const mercySkipBtn = document.getElementById('mercy-skip-btn');
|
|
const skipBtn = document.getElementById('skip-btn');
|
|
|
|
taskText.textContent = this.gameState.currentTask.text;
|
|
|
|
// Set image with error handling
|
|
taskImage.src = this.gameState.currentTask.image;
|
|
taskImage.onerror = () => {
|
|
console.log('Image failed to load:', this.gameState.currentTask.image);
|
|
taskImage.src = this.createPlaceholderImage();
|
|
};
|
|
|
|
// Update task type indicator and button visibility
|
|
if (this.gameState.isConsequenceTask) {
|
|
taskTypeIndicator.textContent = 'CONSEQUENCE TASK';
|
|
taskTypeIndicator.classList.add('consequence');
|
|
// Hide difficulty/points for consequence tasks
|
|
taskDifficulty.style.display = 'none';
|
|
taskPoints.style.display = 'none';
|
|
// Hide regular skip button for consequence tasks
|
|
skipBtn.style.display = 'none';
|
|
|
|
// Show mercy skip button for consequence tasks
|
|
const originalTask = this.findOriginalSkippedTask();
|
|
if (originalTask) {
|
|
const mercyCost = this.getPointsForDifficulty(originalTask.difficulty || 'Medium') * 2;
|
|
const canAfford = this.gameState.score >= mercyCost;
|
|
|
|
if (canAfford) {
|
|
mercySkipBtn.style.display = 'block';
|
|
document.getElementById('mercy-skip-cost').textContent = `-${mercyCost} pts`;
|
|
mercySkipBtn.disabled = false;
|
|
} else {
|
|
mercySkipBtn.style.display = 'block';
|
|
document.getElementById('mercy-skip-cost').textContent = `-${mercyCost} pts (Not enough!)`;
|
|
mercySkipBtn.disabled = true;
|
|
}
|
|
} else {
|
|
mercySkipBtn.style.display = 'none';
|
|
}
|
|
} else {
|
|
taskTypeIndicator.textContent = 'MAIN TASK';
|
|
taskTypeIndicator.classList.remove('consequence');
|
|
|
|
// Show regular skip button for main tasks
|
|
skipBtn.style.display = 'block';
|
|
// Hide mercy skip button for main tasks
|
|
mercySkipBtn.style.display = 'none';
|
|
|
|
// Show difficulty and points for main tasks
|
|
const difficulty = this.gameState.currentTask.difficulty || 'Medium';
|
|
const points = this.getPointsForDifficulty(difficulty);
|
|
const difficultyEmoji = this.getDifficultyEmoji(difficulty);
|
|
|
|
taskDifficulty.textContent = `${difficultyEmoji} ${difficulty}`;
|
|
taskPoints.textContent = `${points} ${points === 1 ? 'point' : 'points'}`;
|
|
taskDifficulty.style.display = 'block';
|
|
taskPoints.style.display = 'inline-block';
|
|
}
|
|
}
|
|
|
|
findOriginalSkippedTask() {
|
|
return this.gameState.lastSkippedTask;
|
|
}
|
|
|
|
mercySkip() {
|
|
if (!this.gameState.isRunning || this.gameState.isPaused) return;
|
|
if (!this.gameState.isConsequenceTask) return;
|
|
|
|
const originalTask = this.findOriginalSkippedTask();
|
|
if (!originalTask) return;
|
|
|
|
const mercyCost = this.getPointsForDifficulty(originalTask.difficulty || 'Medium') * 2;
|
|
|
|
if (this.gameState.score < mercyCost) {
|
|
alert(`Not enough points! You need ${mercyCost} points but only have ${this.gameState.score}.`);
|
|
return;
|
|
}
|
|
|
|
// Confirm the mercy skip
|
|
const confirmed = confirm(
|
|
`Use Mercy Skip?\n\n` +
|
|
`This will cost you ${mercyCost} points from your score.\n` +
|
|
`Your score will go from ${this.gameState.score} to ${this.gameState.score - mercyCost}.\n\n` +
|
|
`Are you sure you want to skip this consequence task?`
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
// Deduct points and skip the consequence task
|
|
this.gameState.score -= mercyCost;
|
|
|
|
// Clear the last skipped task and load next main task
|
|
this.gameState.lastSkippedTask = null;
|
|
this.gameState.isConsequenceTask = false;
|
|
this.loadNextTask();
|
|
this.updateStats();
|
|
|
|
// Show feedback
|
|
alert(`Mercy Skip used! ${mercyCost} points deducted from your score.`);
|
|
}
|
|
|
|
getPointsForDifficulty(difficulty) {
|
|
switch(difficulty) {
|
|
case 'Easy': return 1;
|
|
case 'Medium': return 3;
|
|
case 'Hard': return 5;
|
|
default: return 3;
|
|
}
|
|
}
|
|
|
|
getDifficultyEmoji(difficulty) {
|
|
switch(difficulty) {
|
|
case 'Easy': return '🟢';
|
|
case 'Medium': return '🟡';
|
|
case 'Hard': return '🔴';
|
|
default: return '🟡';
|
|
}
|
|
}
|
|
|
|
completeTask() {
|
|
if (!this.gameState.isRunning || this.gameState.isPaused) return;
|
|
|
|
// Mark task as used and award points
|
|
if (this.gameState.isConsequenceTask) {
|
|
this.gameState.usedConsequenceTasks.push(this.gameState.currentTask.id);
|
|
this.gameState.consequenceCount++;
|
|
// Clear the last skipped task when consequence is completed
|
|
this.gameState.lastSkippedTask = null;
|
|
// Consequence tasks don't award points
|
|
} else {
|
|
this.gameState.usedMainTasks.push(this.gameState.currentTask.id);
|
|
this.gameState.completedCount++;
|
|
// Award points for completing main tasks
|
|
const difficulty = this.gameState.currentTask.difficulty || 'Medium';
|
|
const points = this.getPointsForDifficulty(difficulty);
|
|
this.gameState.score += points;
|
|
}
|
|
|
|
// Load next main task (consequence tasks don't chain)
|
|
this.gameState.isConsequenceTask = false;
|
|
this.loadNextTask();
|
|
|
|
this.updateStats();
|
|
}
|
|
|
|
skipTask() {
|
|
if (!this.gameState.isRunning || this.gameState.isPaused) return;
|
|
|
|
if (this.gameState.isConsequenceTask) {
|
|
// Can't skip consequence tasks - they must be completed
|
|
alert("Consequence tasks cannot be skipped!");
|
|
return;
|
|
}
|
|
|
|
// Store the skipped task for mercy cost calculation
|
|
this.gameState.lastSkippedTask = this.gameState.currentTask;
|
|
|
|
// Mark main task as used and increment skip count
|
|
this.gameState.usedMainTasks.push(this.gameState.currentTask.id);
|
|
this.gameState.skippedCount++;
|
|
|
|
// Load a consequence task
|
|
this.gameState.isConsequenceTask = true;
|
|
this.loadNextTask();
|
|
|
|
this.updateStats();
|
|
}
|
|
|
|
pauseGame() {
|
|
if (!this.gameState.isRunning) return;
|
|
|
|
this.gameState.isPaused = true;
|
|
this.gameState.pausedTime = Date.now();
|
|
this.stopTimer();
|
|
this.musicManager.pause();
|
|
this.showScreen('paused-screen');
|
|
|
|
// Auto-save the paused game state
|
|
this.autoSaveGameState();
|
|
}
|
|
|
|
resumeGame() {
|
|
if (!this.gameState.isRunning || !this.gameState.isPaused) return;
|
|
|
|
this.gameState.totalPausedTime += Date.now() - this.gameState.pausedTime;
|
|
this.gameState.isPaused = false;
|
|
this.startTimer();
|
|
this.musicManager.resume();
|
|
this.showScreen('game-screen');
|
|
}
|
|
|
|
quitGame() {
|
|
this.endGame();
|
|
}
|
|
|
|
endGame() {
|
|
this.gameState.isRunning = false;
|
|
this.stopTimer();
|
|
this.showFinalStats();
|
|
this.showScreen('game-over-screen');
|
|
}
|
|
|
|
resetGame() {
|
|
this.gameState = {
|
|
isRunning: false,
|
|
isPaused: false,
|
|
currentTask: null,
|
|
isConsequenceTask: false,
|
|
startTime: null,
|
|
pausedTime: 0,
|
|
totalPausedTime: 0,
|
|
completedCount: 0,
|
|
skippedCount: 0,
|
|
consequenceCount: 0,
|
|
usedMainTasks: [],
|
|
usedConsequenceTasks: []
|
|
};
|
|
|
|
this.stopTimer();
|
|
this.showScreen('start-screen');
|
|
}
|
|
|
|
startTimer() {
|
|
this.timerInterval = setInterval(() => {
|
|
this.updateTimer();
|
|
}, 1000);
|
|
}
|
|
|
|
stopTimer() {
|
|
if (this.timerInterval) {
|
|
clearInterval(this.timerInterval);
|
|
this.timerInterval = null;
|
|
}
|
|
}
|
|
|
|
updateTimer() {
|
|
if (!this.gameState.isRunning || this.gameState.isPaused) return;
|
|
|
|
const currentTime = Date.now();
|
|
const elapsed = currentTime - this.gameState.startTime - this.gameState.totalPausedTime;
|
|
const formattedTime = this.formatTime(elapsed);
|
|
|
|
document.getElementById('timer').textContent = formattedTime;
|
|
|
|
// Update timer status
|
|
const timerStatus = document.getElementById('timer-status');
|
|
if (this.gameState.isPaused) {
|
|
timerStatus.textContent = '(PAUSED)';
|
|
} else {
|
|
timerStatus.textContent = '';
|
|
}
|
|
}
|
|
|
|
formatTime(milliseconds) {
|
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
updateStats() {
|
|
document.getElementById('completed-count').textContent = this.gameState.completedCount;
|
|
document.getElementById('skipped-count').textContent = this.gameState.skippedCount;
|
|
document.getElementById('consequence-count').textContent = this.gameState.consequenceCount;
|
|
document.getElementById('score').textContent = this.gameState.score;
|
|
}
|
|
|
|
showFinalStats() {
|
|
const currentTime = Date.now();
|
|
const totalTime = currentTime - this.gameState.startTime - this.gameState.totalPausedTime;
|
|
const formattedTime = this.formatTime(totalTime);
|
|
|
|
document.getElementById('final-score').textContent = this.gameState.score;
|
|
document.getElementById('final-time').textContent = formattedTime;
|
|
document.getElementById('final-completed').textContent = this.gameState.completedCount;
|
|
document.getElementById('final-skipped').textContent = this.gameState.skippedCount;
|
|
document.getElementById('final-consequences').textContent = this.gameState.consequenceCount;
|
|
}
|
|
}
|
|
|
|
// Unified Data Management System
|
|
class DataManager {
|
|
constructor() {
|
|
this.version = "1.0";
|
|
this.storageKey = "webGame-data";
|
|
this.data = this.loadData();
|
|
this.autoSaveInterval = null;
|
|
this.startAutoSave();
|
|
}
|
|
|
|
getDefaultData() {
|
|
return {
|
|
version: this.version,
|
|
timestamp: Date.now(),
|
|
settings: {
|
|
theme: "default",
|
|
difficulty: "medium",
|
|
music: {
|
|
volume: 30,
|
|
loopMode: 0,
|
|
shuffle: false,
|
|
currentTrack: 0
|
|
}
|
|
},
|
|
gameplay: {
|
|
totalGamesPlayed: 0,
|
|
totalTasksCompleted: 0,
|
|
totalTasksSkipped: 0,
|
|
bestScore: 0,
|
|
currentStreak: 0,
|
|
longestStreak: 0,
|
|
totalPlayTime: 0,
|
|
mercyPointsEarned: 0,
|
|
mercyPointsSpent: 0
|
|
},
|
|
customContent: {
|
|
tasks: {
|
|
main: [],
|
|
consequence: []
|
|
},
|
|
customImages: [],
|
|
disabledImages: []
|
|
},
|
|
statistics: {
|
|
taskCompletionRate: 0,
|
|
averageGameDuration: 0,
|
|
difficultyStats: {
|
|
easy: { played: 0, completed: 0 },
|
|
medium: { played: 0, completed: 0 },
|
|
hard: { played: 0, completed: 0 }
|
|
},
|
|
themeUsage: {},
|
|
lastPlayed: null
|
|
}
|
|
};
|
|
}
|
|
|
|
loadData() {
|
|
try {
|
|
// Try new unified format first
|
|
const unified = localStorage.getItem(this.storageKey);
|
|
if (unified) {
|
|
const data = JSON.parse(unified);
|
|
return this.migrateData(data);
|
|
}
|
|
|
|
// Migrate from old scattered localStorage
|
|
return this.migrateFromOldFormat();
|
|
} catch (error) {
|
|
console.warn('Data loading failed, using defaults:', error);
|
|
return this.getDefaultData();
|
|
}
|
|
}
|
|
|
|
migrateFromOldFormat() {
|
|
const data = this.getDefaultData();
|
|
|
|
// Migrate existing scattered data
|
|
try {
|
|
// Theme
|
|
const theme = localStorage.getItem('selectedTheme');
|
|
if (theme) data.settings.theme = theme;
|
|
|
|
// Music settings
|
|
const volume = localStorage.getItem('gameMusic-volume');
|
|
if (volume) data.settings.music.volume = parseInt(volume);
|
|
|
|
const track = localStorage.getItem('gameMusic-track');
|
|
if (track) data.settings.music.currentTrack = parseInt(track);
|
|
|
|
const loopMode = localStorage.getItem('gameMusic-loopMode');
|
|
if (loopMode) data.settings.music.loopMode = parseInt(loopMode);
|
|
|
|
const shuffle = localStorage.getItem('gameMusic-shuffleMode');
|
|
if (shuffle) data.settings.music.shuffle = shuffle === 'true';
|
|
|
|
// Custom tasks
|
|
const mainTasks = localStorage.getItem('customMainTasks');
|
|
if (mainTasks) data.customContent.tasks.main = JSON.parse(mainTasks);
|
|
|
|
const consequenceTasks = localStorage.getItem('customConsequenceTasks');
|
|
if (consequenceTasks) data.customContent.tasks.consequence = JSON.parse(consequenceTasks);
|
|
|
|
// Difficulty
|
|
const difficulty = localStorage.getItem('selectedDifficulty');
|
|
if (difficulty) data.settings.difficulty = difficulty;
|
|
|
|
console.log('Migrated data from old format');
|
|
this.saveData(); // Save in new format
|
|
this.cleanupOldStorage(); // Remove old keys
|
|
|
|
} catch (error) {
|
|
console.warn('Migration failed, using defaults:', error);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
migrateData(data) {
|
|
// Handle version upgrades here
|
|
if (!data.version || data.version < this.version) {
|
|
console.log(`Upgrading data from ${data.version || 'unknown'} to ${this.version}`);
|
|
|
|
// Add any missing properties from default
|
|
const defaultData = this.getDefaultData();
|
|
data = this.deepMerge(defaultData, data);
|
|
data.version = this.version;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
deepMerge(target, source) {
|
|
const result = { ...target };
|
|
|
|
for (const key in source) {
|
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
result[key] = this.deepMerge(target[key] || {}, source[key]);
|
|
} else {
|
|
result[key] = source[key];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
cleanupOldStorage() {
|
|
// Remove old scattered localStorage keys
|
|
const oldKeys = [
|
|
'selectedTheme', 'gameMusic-volume', 'gameMusic-track',
|
|
'gameMusic-loopMode', 'gameMusic-shuffleMode',
|
|
'customMainTasks', 'customConsequenceTasks', 'selectedDifficulty'
|
|
];
|
|
|
|
oldKeys.forEach(key => localStorage.removeItem(key));
|
|
console.log('Cleaned up old storage keys');
|
|
}
|
|
|
|
saveData() {
|
|
try {
|
|
if (!this.data) {
|
|
this.data = this.getDefaultData();
|
|
}
|
|
this.data.timestamp = Date.now();
|
|
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
|
|
} catch (error) {
|
|
console.error('Failed to save data:', error);
|
|
throw error; // Re-throw so calling code can handle it
|
|
}
|
|
}
|
|
|
|
// Settings Management
|
|
getSetting(path) {
|
|
return this.getNestedValue(this.data.settings, path);
|
|
}
|
|
|
|
setSetting(path, value) {
|
|
this.setNestedValue(this.data.settings, path, value);
|
|
this.saveData();
|
|
}
|
|
|
|
// Generic data access
|
|
get(key) {
|
|
// Handle special cases first
|
|
if (key === 'autoSaveGameState') {
|
|
return JSON.parse(localStorage.getItem('autoSaveGameState') || 'null');
|
|
}
|
|
|
|
// For custom images and disabled images, store them in customContent
|
|
if (key === 'customImages') {
|
|
return this.data.customContent.customImages || [];
|
|
}
|
|
|
|
if (key === 'disabledImages') {
|
|
return this.data.customContent.disabledImages || [];
|
|
}
|
|
|
|
// Generic access to data properties
|
|
return this.data[key];
|
|
}
|
|
|
|
set(key, value) {
|
|
// Handle special cases first
|
|
if (key === 'autoSaveGameState') {
|
|
localStorage.setItem('autoSaveGameState', JSON.stringify(value));
|
|
return;
|
|
}
|
|
|
|
// For custom images and disabled images, store them in customContent
|
|
if (key === 'customImages') {
|
|
if (!this.data.customContent.customImages) {
|
|
this.data.customContent.customImages = [];
|
|
}
|
|
this.data.customContent.customImages = value;
|
|
this.saveData();
|
|
return;
|
|
}
|
|
|
|
if (key === 'disabledImages') {
|
|
if (!this.data.customContent.disabledImages) {
|
|
this.data.customContent.disabledImages = [];
|
|
}
|
|
this.data.customContent.disabledImages = value;
|
|
this.saveData();
|
|
return;
|
|
}
|
|
|
|
// Generic setter
|
|
this.data[key] = value;
|
|
this.saveData();
|
|
}
|
|
|
|
// Gameplay Statistics
|
|
recordGameStart() {
|
|
this.data.gameplay.totalGamesPlayed++;
|
|
this.data.statistics.lastPlayed = Date.now();
|
|
this.saveData();
|
|
}
|
|
|
|
recordTaskComplete(difficulty, points, isConsequence = false) {
|
|
this.data.gameplay.totalTasksCompleted++;
|
|
|
|
if (!isConsequence) {
|
|
this.data.gameplay.currentStreak++;
|
|
this.data.gameplay.longestStreak = Math.max(
|
|
this.data.gameplay.longestStreak,
|
|
this.data.gameplay.currentStreak
|
|
);
|
|
|
|
// Track difficulty stats
|
|
if (this.data.statistics.difficultyStats[difficulty]) {
|
|
this.data.statistics.difficultyStats[difficulty].completed++;
|
|
}
|
|
}
|
|
|
|
this.updateStatistics();
|
|
this.saveData();
|
|
}
|
|
|
|
recordTaskSkip(mercyPoints = 0) {
|
|
this.data.gameplay.totalTasksSkipped++;
|
|
this.data.gameplay.currentStreak = 0;
|
|
|
|
if (mercyPoints > 0) {
|
|
this.data.gameplay.mercyPointsSpent += mercyPoints;
|
|
}
|
|
|
|
this.updateStatistics();
|
|
this.saveData();
|
|
}
|
|
|
|
recordScore(score) {
|
|
this.data.gameplay.bestScore = Math.max(this.data.gameplay.bestScore, score);
|
|
this.saveData();
|
|
}
|
|
|
|
recordPlayTime(duration) {
|
|
this.data.gameplay.totalPlayTime += duration;
|
|
this.updateStatistics();
|
|
this.saveData();
|
|
}
|
|
|
|
updateStatistics() {
|
|
const { totalTasksCompleted, totalTasksSkipped } = this.data.gameplay;
|
|
const total = totalTasksCompleted + totalTasksSkipped;
|
|
|
|
this.data.statistics.taskCompletionRate = total > 0 ? totalTasksCompleted / total : 0;
|
|
|
|
if (this.data.gameplay.totalGamesPlayed > 0) {
|
|
this.data.statistics.averageGameDuration =
|
|
this.data.gameplay.totalPlayTime / this.data.gameplay.totalGamesPlayed;
|
|
}
|
|
}
|
|
|
|
// Custom Content Management
|
|
getCustomTasks(type = 'main') {
|
|
return this.data.customContent.tasks[type] || [];
|
|
}
|
|
|
|
addCustomTask(task, type = 'main') {
|
|
if (!this.data.customContent.tasks[type]) {
|
|
this.data.customContent.tasks[type] = [];
|
|
}
|
|
this.data.customContent.tasks[type].push(task);
|
|
this.saveData();
|
|
}
|
|
|
|
removeCustomTask(index, type = 'main') {
|
|
if (this.data.customContent.tasks[type]) {
|
|
this.data.customContent.tasks[type].splice(index, 1);
|
|
this.saveData();
|
|
}
|
|
}
|
|
|
|
resetCustomTasks(type = 'main') {
|
|
this.data.customContent.tasks[type] = [];
|
|
this.saveData();
|
|
}
|
|
|
|
// Export/Import System
|
|
exportData(includeStats = true) {
|
|
const exportData = {
|
|
version: this.version,
|
|
exportDate: new Date().toISOString(),
|
|
settings: this.data.settings,
|
|
customContent: this.data.customContent
|
|
};
|
|
|
|
if (includeStats) {
|
|
exportData.gameplay = this.data.gameplay;
|
|
exportData.statistics = this.data.statistics;
|
|
}
|
|
|
|
const dataStr = JSON.stringify(exportData, null, 2);
|
|
const blob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `webgame-save-${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
return exportData;
|
|
}
|
|
|
|
importData(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
try {
|
|
const importedData = JSON.parse(e.target.result);
|
|
|
|
// Validate import
|
|
if (!this.validateImportData(importedData)) {
|
|
throw new Error('Invalid save file format');
|
|
}
|
|
|
|
// Merge imported data
|
|
this.data = this.deepMerge(this.getDefaultData(), importedData);
|
|
this.data.version = this.version;
|
|
this.data.timestamp = Date.now();
|
|
|
|
this.saveData();
|
|
resolve(importedData);
|
|
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
validateImportData(data) {
|
|
// Basic validation
|
|
return data &&
|
|
typeof data === 'object' &&
|
|
(data.settings || data.customContent || data.gameplay);
|
|
}
|
|
|
|
// Utility functions
|
|
getNestedValue(obj, path) {
|
|
return path.split('.').reduce((current, key) => current && current[key], obj);
|
|
}
|
|
|
|
setNestedValue(obj, path, value) {
|
|
const keys = path.split('.');
|
|
const lastKey = keys.pop();
|
|
const target = keys.reduce((current, key) => {
|
|
if (!current[key]) current[key] = {};
|
|
return current[key];
|
|
}, obj);
|
|
target[lastKey] = value;
|
|
}
|
|
|
|
// Auto-save functionality
|
|
startAutoSave() {
|
|
this.autoSaveInterval = setInterval(() => {
|
|
this.saveData();
|
|
}, 30000); // Save every 30 seconds
|
|
}
|
|
|
|
stopAutoSave() {
|
|
if (this.autoSaveInterval) {
|
|
clearInterval(this.autoSaveInterval);
|
|
this.autoSaveInterval = null;
|
|
}
|
|
}
|
|
|
|
// Statistics getters for UI
|
|
getStats() {
|
|
return {
|
|
...this.data.gameplay,
|
|
...this.data.statistics,
|
|
hoursPlayed: Math.round(this.data.gameplay.totalPlayTime / 3600000 * 10) / 10,
|
|
completionPercentage: Math.round(this.data.statistics.taskCompletionRate * 100)
|
|
};
|
|
}
|
|
|
|
// Reset functionality
|
|
resetAllData() {
|
|
if (confirm('Are you sure you want to reset all data? This cannot be undone!')) {
|
|
this.data = this.getDefaultData();
|
|
this.saveData();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
resetStats() {
|
|
if (confirm('Reset all statistics? Your settings and custom tasks will be kept.')) {
|
|
this.data.gameplay = this.getDefaultData().gameplay;
|
|
this.data.statistics = this.getDefaultData().statistics;
|
|
this.saveData();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Simple Music Management System with Playlist Controls
|
|
class MusicManager {
|
|
constructor(dataManager) {
|
|
this.dataManager = dataManager;
|
|
this.currentAudio = null;
|
|
this.isPlaying = false;
|
|
this.volume = this.dataManager.getSetting('music.volume') || 30;
|
|
this.loopMode = this.dataManager.getSetting('music.loopMode') || 0;
|
|
this.shuffleMode = this.dataManager.getSetting('music.shuffle') || false;
|
|
this.currentTrackIndex = this.dataManager.getSetting('music.currentTrack') || 0;
|
|
this.playHistory = [];
|
|
|
|
this.tracks = [
|
|
{ name: 'Colorful Flowers', file: 'audio/Colorful-Flowers(chosic.com).mp3' },
|
|
{ name: 'New Beginnings', file: 'audio/New-Beginnings-chosic.com_.mp3' },
|
|
{ name: 'Storm Clouds', file: 'audio/storm-clouds-purpple-cat(chosic.com).mp3' },
|
|
{ name: 'Brunch For Two', file: 'audio/Tokyo-Music-Walker-Brunch-For-Two-chosic.com_.mp3' }
|
|
];
|
|
|
|
this.updateUI();
|
|
this.initializeVolumeUI();
|
|
}
|
|
|
|
saveSettings() {
|
|
this.dataManager.setSetting('music.volume', this.volume);
|
|
this.dataManager.setSetting('music.currentTrack', this.currentTrackIndex);
|
|
this.dataManager.setSetting('music.loopMode', this.loopMode);
|
|
this.dataManager.setSetting('music.shuffle', this.shuffleMode);
|
|
}
|
|
|
|
initializeVolumeUI() {
|
|
const volumeSlider = document.getElementById('volume-slider');
|
|
const volumePercent = document.getElementById('volume-percent');
|
|
const trackSelector = document.getElementById('track-selector');
|
|
|
|
volumeSlider.value = this.volume;
|
|
volumePercent.textContent = `${this.volume}%`;
|
|
trackSelector.value = this.currentTrackIndex;
|
|
|
|
this.updateLoopButton();
|
|
this.updateShuffleButton();
|
|
}
|
|
|
|
// Loop mode cycling: 0 -> 1 -> 2 -> 0
|
|
toggleLoopMode() {
|
|
this.loopMode = (this.loopMode + 1) % 3;
|
|
this.saveSettings();
|
|
this.updateLoopButton();
|
|
this.updateAudioLoop();
|
|
}
|
|
|
|
toggleShuffleMode() {
|
|
this.shuffleMode = !this.shuffleMode;
|
|
this.saveSettings();
|
|
this.updateShuffleButton();
|
|
if (this.shuffleMode) {
|
|
this.playHistory = [this.currentTrackIndex]; // Start tracking
|
|
}
|
|
}
|
|
|
|
updateLoopButton() {
|
|
const loopBtn = document.getElementById('loop-btn');
|
|
switch(this.loopMode) {
|
|
case 0:
|
|
loopBtn.classList.remove('active');
|
|
loopBtn.innerHTML = '🔁';
|
|
loopBtn.title = 'Loop: Off';
|
|
break;
|
|
case 1:
|
|
loopBtn.classList.add('active');
|
|
loopBtn.innerHTML = '🔁';
|
|
loopBtn.title = 'Loop: Playlist';
|
|
break;
|
|
case 2:
|
|
loopBtn.classList.add('active');
|
|
loopBtn.innerHTML = '🔂';
|
|
loopBtn.title = 'Loop: Current Track';
|
|
break;
|
|
}
|
|
}
|
|
|
|
updateShuffleButton() {
|
|
const shuffleBtn = document.getElementById('shuffle-btn');
|
|
if (this.shuffleMode) {
|
|
shuffleBtn.classList.add('active');
|
|
shuffleBtn.title = 'Shuffle: On';
|
|
} else {
|
|
shuffleBtn.classList.remove('active');
|
|
shuffleBtn.title = 'Shuffle: Off';
|
|
}
|
|
}
|
|
|
|
updateAudioLoop() {
|
|
if (this.currentAudio) {
|
|
this.currentAudio.loop = (this.loopMode === 2);
|
|
}
|
|
}
|
|
|
|
getNextTrack() {
|
|
if (this.shuffleMode) {
|
|
return this.getShuffledNextTrack();
|
|
} else {
|
|
return (this.currentTrackIndex + 1) % this.tracks.length;
|
|
}
|
|
}
|
|
|
|
getShuffledNextTrack() {
|
|
if (this.tracks.length <= 1) return 0;
|
|
|
|
let availableTracks = [];
|
|
for (let i = 0; i < this.tracks.length; i++) {
|
|
// Avoid last 2 played tracks if possible
|
|
if (this.playHistory.length < 2 || !this.playHistory.slice(-2).includes(i)) {
|
|
availableTracks.push(i);
|
|
}
|
|
}
|
|
|
|
// If no tracks available (shouldn't happen with 4+ tracks), use all except current
|
|
if (availableTracks.length === 0) {
|
|
availableTracks = this.tracks.map((_, i) => i).filter(i => i !== this.currentTrackIndex);
|
|
}
|
|
|
|
return availableTracks[Math.floor(Math.random() * availableTracks.length)];
|
|
}
|
|
|
|
// Called when track ends naturally
|
|
onTrackEnded() {
|
|
if (this.loopMode === 2) {
|
|
// Loop current track - handled by audio.loop = true
|
|
return;
|
|
} else if (this.loopMode === 1) {
|
|
// Loop playlist - go to next track
|
|
this.playNextTrack();
|
|
} else {
|
|
// No loop - stop playing
|
|
this.isPlaying = false;
|
|
this.updateUI();
|
|
}
|
|
}
|
|
|
|
playNextTrack() {
|
|
const nextIndex = this.getNextTrack();
|
|
this.changeTrack(nextIndex);
|
|
|
|
// Update play history for shuffle
|
|
if (this.shuffleMode) {
|
|
this.playHistory.push(nextIndex);
|
|
// Keep only last 3 tracks in history
|
|
if (this.playHistory.length > 3) {
|
|
this.playHistory.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
toggle() {
|
|
if (this.isPlaying) {
|
|
this.stop();
|
|
} else {
|
|
this.play();
|
|
}
|
|
}
|
|
|
|
play() {
|
|
try {
|
|
if (!this.currentAudio) {
|
|
this.loadCurrentTrack();
|
|
}
|
|
|
|
this.currentAudio.play();
|
|
this.isPlaying = true;
|
|
this.updateUI();
|
|
} catch (error) {
|
|
console.log('Audio playback failed:', error);
|
|
// Fallback to silent mode
|
|
this.isPlaying = true;
|
|
this.updateUI();
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
if (this.currentAudio) {
|
|
this.currentAudio.pause();
|
|
this.currentAudio.currentTime = 0;
|
|
}
|
|
this.isPlaying = false;
|
|
this.updateUI();
|
|
}
|
|
|
|
pause() {
|
|
if (this.currentAudio && this.isPlaying) {
|
|
this.currentAudio.pause();
|
|
}
|
|
}
|
|
|
|
resume() {
|
|
if (this.currentAudio && this.isPlaying) {
|
|
try {
|
|
this.currentAudio.play();
|
|
} catch (error) {
|
|
console.log('Audio resume failed:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
changeTrack(trackIndex) {
|
|
const wasPlaying = this.isPlaying;
|
|
|
|
if (this.currentAudio) {
|
|
this.currentAudio.pause();
|
|
this.currentAudio = null;
|
|
}
|
|
|
|
this.currentTrackIndex = trackIndex;
|
|
this.saveSettings();
|
|
|
|
if (wasPlaying) {
|
|
this.play();
|
|
} else {
|
|
this.updateUI();
|
|
}
|
|
}
|
|
|
|
setVolume(volume) {
|
|
this.volume = volume;
|
|
this.saveSettings();
|
|
|
|
if (this.currentAudio) {
|
|
this.currentAudio.volume = volume / 100;
|
|
}
|
|
|
|
const volumePercent = document.getElementById('volume-percent');
|
|
volumePercent.textContent = `${volume}%`;
|
|
|
|
// Update volume icon based on level
|
|
const volumeIcon = document.querySelector('.volume-icon');
|
|
if (volume === 0) {
|
|
volumeIcon.textContent = '🔇';
|
|
} else if (volume < 30) {
|
|
volumeIcon.textContent = '🔉';
|
|
} else {
|
|
volumeIcon.textContent = '🔊';
|
|
}
|
|
}
|
|
|
|
loadCurrentTrack() {
|
|
const track = this.tracks[this.currentTrackIndex];
|
|
|
|
// Create audio element with fallback
|
|
this.currentAudio = new Audio();
|
|
this.currentAudio.volume = this.volume / 100;
|
|
this.updateAudioLoop();
|
|
|
|
// Set source with error handling
|
|
this.currentAudio.src = track.file;
|
|
|
|
// Handle loading errors gracefully
|
|
this.currentAudio.onerror = () => {
|
|
console.log(`Could not load audio: ${track.file}`);
|
|
// Continue without audio
|
|
};
|
|
|
|
// Handle successful loading
|
|
this.currentAudio.onloadeddata = () => {
|
|
console.log(`Loaded audio: ${track.name}`);
|
|
};
|
|
|
|
// Handle track ending
|
|
this.currentAudio.onended = () => {
|
|
if (this.isPlaying) {
|
|
this.onTrackEnded();
|
|
}
|
|
};
|
|
}
|
|
|
|
updateUI() {
|
|
const toggleBtn = document.getElementById('music-toggle');
|
|
const statusSpan = document.getElementById('music-status');
|
|
const trackSelector = document.getElementById('track-selector');
|
|
const currentTrack = this.tracks[this.currentTrackIndex];
|
|
|
|
// Update track selector
|
|
trackSelector.value = this.currentTrackIndex;
|
|
|
|
if (this.isPlaying) {
|
|
toggleBtn.classList.add('playing');
|
|
toggleBtn.innerHTML = '⏸️';
|
|
|
|
// Show current mode in status
|
|
let modeText = '';
|
|
if (this.shuffleMode) modeText += ' 🔀';
|
|
if (this.loopMode === 1) modeText += ' 🔁';
|
|
else if (this.loopMode === 2) modeText += ' 🔂';
|
|
|
|
statusSpan.textContent = `♪ ${currentTrack.name}${modeText}`;
|
|
} else {
|
|
toggleBtn.classList.remove('playing');
|
|
toggleBtn.innerHTML = '🎵';
|
|
statusSpan.textContent = 'Music: Off';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize game when page loads
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.game = new TaskChallengeGame();
|
|
}); |