/** * SCREEN READER SYSTEM * Provides audio narration and accessibility for blind/visually impaired players * Compatible with NVDA, JAWS, VoiceOver, and other screen readers */ class ScreenReaderSystem { constructor(scene) { this.scene = scene; this.enabled = true; // Speech synthesis this.synth = window.speechSynthesis; this.voice = null; this.voices = []; // Settings this.settings = { enabled: false, // DISABLED by default rate: 1.0, // 0.1 - 10 (speech speed) pitch: 1.0, // 0 - 2 (voice pitch) volume: 1.0, // 0 - 1 (volume) language: 'en-US', // Voice language autoNarrate: false, // Auto-narrate UI changes verboseMode: false, // Detailed descriptions soundCues: false, // Audio cues for actions navigationHelp: false // Navigation assistance }; // ARIA live regions (for screen reader announcements) this.liveRegion = null; this.alertRegion = null; // Navigation state this.currentFocus = null; this.navigationHistory = []; this.maxHistorySize = 50; // Audio cues (simple beeps/tones) this.audioCues = { 'focus': { frequency: 440, duration: 100 }, 'select': { frequency: 880, duration: 150 }, 'error': { frequency: 220, duration: 300 }, 'success': { frequency: 660, duration: 200 }, 'navigation': { frequency: 550, duration: 80 }, 'inventory': { frequency: 750, duration: 120 }, 'damage': { frequency: 200, duration: 250 }, 'pickup': { frequency: 1000, duration: 100 } }; // Context descriptions this.contextDescriptions = { 'menu': 'Main menu. Use arrow keys to navigate, Enter to select.', 'game': 'In game. Use WASD to move, E to interact, I for inventory.', 'inventory': 'Inventory screen. Use arrow keys to navigate items, Enter to use.', 'crafting': 'Crafting menu. Use arrow keys to browse recipes, Enter to craft.', 'dialogue': 'Dialogue. Press Space to continue, Escape to skip.', 'combat': 'In combat. Use J to attack, Space to dodge.', 'building': 'Build mode. Use arrow keys to select building, Enter to place.', 'map': 'Map view. Use arrow keys to pan, M to close.' }; // UI element descriptions this.elementDescriptions = new Map(); this.loadSettings(); this.init(); console.log('✅ Screen Reader System initialized'); } init() { // Load available voices this.loadVoices(); // Create ARIA live regions this.createLiveRegions(); // Set up speech synthesis event listeners this.setupSpeechListeners(); // Set up keyboard navigation this.setupKeyboardNavigation(); // Announce system ready this.speak('Screen reader system ready. Press H for help.'); } /** * Load available speech synthesis voices */ loadVoices() { this.voices = this.synth.getVoices(); // If voices not loaded yet, wait for event if (this.voices.length === 0) { this.synth.addEventListener('voiceschanged', () => { this.voices = this.synth.getVoices(); this.selectVoice(); }); } else { this.selectVoice(); } } /** * Select appropriate voice based on language setting */ selectVoice() { if (this.voices.length === 0) return; // Try to find voice matching language this.voice = this.voices.find(v => v.lang === this.settings.language); // Fallback to first available voice if (!this.voice) { this.voice = this.voices[0]; } console.log(`🔊 Selected voice: ${this.voice.name} (${this.voice.lang})`); } /** * Create ARIA live regions for screen reader announcements */ createLiveRegions() { // Polite region (non-interrupting) this.liveRegion = document.createElement('div'); this.liveRegion.setAttribute('role', 'status'); this.liveRegion.setAttribute('aria-live', 'polite'); this.liveRegion.setAttribute('aria-atomic', 'true'); this.liveRegion.style.position = 'absolute'; this.liveRegion.style.left = '-10000px'; this.liveRegion.style.width = '1px'; this.liveRegion.style.height = '1px'; this.liveRegion.style.overflow = 'hidden'; document.body.appendChild(this.liveRegion); // Alert region (interrupting) this.alertRegion = document.createElement('div'); this.alertRegion.setAttribute('role', 'alert'); this.alertRegion.setAttribute('aria-live', 'assertive'); this.alertRegion.setAttribute('aria-atomic', 'true'); this.alertRegion.style.position = 'absolute'; this.alertRegion.style.left = '-10000px'; this.alertRegion.style.width = '1px'; this.alertRegion.style.height = '1px'; this.alertRegion.style.overflow = 'hidden'; document.body.appendChild(this.alertRegion); } /** * Set up speech synthesis event listeners */ setupSpeechListeners() { this.synth.addEventListener('error', (event) => { console.error('Speech synthesis error:', event); }); } /** * Set up keyboard navigation for screen reader users */ setupKeyboardNavigation() { // H key - Help this.scene.input.keyboard.on('keydown-H', () => { if (this.scene.input.keyboard.checkDown(this.scene.input.keyboard.addKey('CTRL'))) { this.announceHelp(); } }); // Ctrl+R - Repeat last announcement this.scene.input.keyboard.on('keydown-R', () => { if (this.scene.input.keyboard.checkDown(this.scene.input.keyboard.addKey('CTRL'))) { this.repeatLast(); } }); // Ctrl+S - Settings this.scene.input.keyboard.on('keydown-S', () => { if (this.scene.input.keyboard.checkDown(this.scene.input.keyboard.addKey('CTRL'))) { this.announceSettings(); } }); } /** * Speak text using speech synthesis */ speak(text, priority = 'normal', interrupt = false) { if (!this.settings.enabled || !text) return; // Cancel current speech if interrupting if (interrupt) { this.synth.cancel(); } // Create utterance const utterance = new SpeechSynthesisUtterance(text); utterance.voice = this.voice; utterance.rate = this.settings.rate; utterance.pitch = this.settings.pitch; utterance.volume = this.settings.volume; // Speak this.synth.speak(utterance); // Update ARIA live region if (priority === 'alert') { this.alertRegion.textContent = text; } else { this.liveRegion.textContent = text; } // Add to history this.addToHistory(text); console.log(`🔊 Speaking: "${text}"`); } /** * Stop current speech */ stop() { this.synth.cancel(); } /** * Add text to navigation history */ addToHistory(text) { this.navigationHistory.unshift(text); if (this.navigationHistory.length > this.maxHistorySize) { this.navigationHistory.pop(); } } /** * Repeat last announcement */ repeatLast() { if (this.navigationHistory.length > 0) { this.speak(this.navigationHistory[0], 'normal', true); } } /** * Play audio cue */ playAudioCue(cueType) { if (!this.settings.soundCues) return; const cue = this.audioCues[cueType]; if (!cue) return; // Create audio context const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = cue.frequency; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + cue.duration / 1000); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + cue.duration / 1000); } /** * Announce game context */ announceContext(context) { const description = this.contextDescriptions[context]; if (description) { this.speak(description, 'alert', true); this.playAudioCue('navigation'); } } /** * Announce player stats */ announceStats() { if (!this.scene.player || !this.scene.statsSystem) return; const stats = this.scene.statsSystem; const text = `Health: ${Math.round(stats.health)} out of ${stats.maxHealth}. ` + `Hunger: ${Math.round(stats.hunger)} percent. ` + `Stamina: ${Math.round(stats.stamina)} percent.`; this.speak(text); } /** * Announce inventory */ announceInventory() { if (!this.scene.inventorySystem) return; const inv = this.scene.inventorySystem; const itemCount = Object.keys(inv.items).length; const gold = inv.gold || 0; let text = `Inventory. ${itemCount} item types. ${gold} gold. `; // List items if (this.settings.verboseMode) { for (const [item, count] of Object.entries(inv.items)) { text += `${item}: ${count}. `; } } else { text += 'Press V for verbose item list.'; } this.speak(text); } /** * Announce position */ announcePosition() { if (!this.scene.player) return; const pos = this.scene.player.getPosition(); const text = `Position: X ${Math.round(pos.x)}, Y ${Math.round(pos.y)}.`; this.speak(text); } /** * Announce nearby objects */ announceNearby() { if (!this.scene.player || !this.scene.terrainSystem) return; const pos = this.scene.player.getPosition(); const x = Math.floor(pos.x); const y = Math.floor(pos.y); let text = 'Nearby: '; let foundObjects = false; // Check decorations for (let dx = -2; dx <= 2; dx++) { for (let dy = -2; dy <= 2; dy++) { if (dx === 0 && dy === 0) continue; const key = `${x + dx},${y + dy}`; if (this.scene.terrainSystem.decorationsMap.has(key)) { const decoration = this.scene.terrainSystem.decorationsMap.get(key); const direction = this.getDirection(dx, dy); text += `${decoration.type} ${direction}. `; foundObjects = true; } } } if (!foundObjects) { text += 'Nothing nearby.'; } this.speak(text); } /** * Get direction description */ getDirection(dx, dy) { if (dx === 0 && dy < 0) return 'north'; if (dx === 0 && dy > 0) return 'south'; if (dx < 0 && dy === 0) return 'west'; if (dx > 0 && dy === 0) return 'east'; if (dx < 0 && dy < 0) return 'northwest'; if (dx > 0 && dy < 0) return 'northeast'; if (dx < 0 && dy > 0) return 'southwest'; if (dx > 0 && dy > 0) return 'southeast'; return 'nearby'; } /** * Announce help */ announceHelp() { const text = 'Screen reader help. ' + 'Press Ctrl H for help. ' + 'Press Ctrl R to repeat last announcement. ' + 'Press Ctrl S for settings. ' + 'Press Ctrl P for position. ' + 'Press Ctrl I for inventory. ' + 'Press Ctrl N for nearby objects. ' + 'Press Ctrl T for stats. ' + 'Press Ctrl V to toggle verbose mode.'; this.speak(text, 'alert', true); } /** * Announce settings */ announceSettings() { const text = `Screen reader settings. ` + `Speed: ${this.settings.rate}. ` + `Pitch: ${this.settings.pitch}. ` + `Volume: ${this.settings.volume}. ` + `Verbose mode: ${this.settings.verboseMode ? 'on' : 'off'}. ` + `Sound cues: ${this.settings.soundCues ? 'on' : 'off'}.`; this.speak(text); } /** * Announce action */ announceAction(action, details = '') { const actionDescriptions = { 'move': 'Moving', 'attack': 'Attacking', 'interact': 'Interacting', 'pickup': 'Picked up', 'drop': 'Dropped', 'craft': 'Crafted', 'build': 'Built', 'harvest': 'Harvested', 'plant': 'Planted', 'dig': 'Digging', 'damage': 'Took damage', 'heal': 'Healed', 'die': 'You died', 'respawn': 'Respawned' }; const description = actionDescriptions[action] || action; const text = details ? `${description}: ${details}` : description; this.speak(text); this.playAudioCue(action); } /** * Announce UI change */ announceUI(element, state = '') { if (!this.settings.autoNarrate) return; const text = state ? `${element}: ${state}` : element; this.speak(text); this.playAudioCue('navigation'); } /** * Announce notification */ announceNotification(message, priority = 'normal') { this.speak(message, priority); this.playAudioCue(priority === 'alert' ? 'error' : 'success'); } /** * Set speech rate */ setRate(rate) { this.settings.rate = Phaser.Math.Clamp(rate, 0.1, 10); this.saveSettings(); this.speak(`Speech rate set to ${this.settings.rate}`); } /** * Set speech pitch */ setPitch(pitch) { this.settings.pitch = Phaser.Math.Clamp(pitch, 0, 2); this.saveSettings(); this.speak(`Speech pitch set to ${this.settings.pitch}`); } /** * Set speech volume */ setVolume(volume) { this.settings.volume = Phaser.Math.Clamp(volume, 0, 1); this.saveSettings(); this.speak(`Speech volume set to ${Math.round(this.settings.volume * 100)} percent`); } /** * Toggle verbose mode */ toggleVerboseMode() { this.settings.verboseMode = !this.settings.verboseMode; this.saveSettings(); this.speak(`Verbose mode ${this.settings.verboseMode ? 'enabled' : 'disabled'}`); } /** * Toggle sound cues */ toggleSoundCues() { this.settings.soundCues = !this.settings.soundCues; this.saveSettings(); this.speak(`Sound cues ${this.settings.soundCues ? 'enabled' : 'disabled'}`); } /** * Toggle auto-narrate */ toggleAutoNarrate() { this.settings.autoNarrate = !this.settings.autoNarrate; this.saveSettings(); this.speak(`Auto narration ${this.settings.autoNarrate ? 'enabled' : 'disabled'}`); } /** * Get available voices */ getAvailableVoices() { return this.voices.map(v => ({ name: v.name, lang: v.lang, default: v.default, localService: v.localService })); } /** * Set voice by name */ setVoice(voiceName) { const voice = this.voices.find(v => v.name === voiceName); if (voice) { this.voice = voice; this.saveSettings(); this.speak(`Voice changed to ${voice.name}`); } } /** * Save settings to localStorage */ saveSettings() { localStorage.setItem('novafarma_screen_reader', JSON.stringify(this.settings)); } /** * Load settings from localStorage */ loadSettings() { const saved = localStorage.getItem('novafarma_screen_reader'); if (saved) { try { this.settings = { ...this.settings, ...JSON.parse(saved) }; console.log('✅ Screen reader settings loaded'); } catch (error) { console.error('❌ Failed to load screen reader settings:', error); } } } /** * Update (called every frame) */ update() { // Auto-announce important changes if (this.settings.autoNarrate) { // Check for low health if (this.scene.statsSystem && this.scene.statsSystem.health < 20) { if (!this.lowHealthWarned) { this.speak('Warning: Low health!', 'alert'); this.playAudioCue('damage'); this.lowHealthWarned = true; } } else { this.lowHealthWarned = false; } } } /** * Destroy system */ destroy() { this.stop(); if (this.liveRegion) this.liveRegion.remove(); if (this.alertRegion) this.alertRegion.remove(); console.log('🔊 Screen Reader System destroyed'); } }