Files
novafarma/src/systems/ScreenReaderSystem.js
2025-12-13 00:58:46 +01:00

591 lines
17 KiB
JavaScript

/**
* 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');
}
}