591 lines
17 KiB
JavaScript
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');
|
|
}
|
|
}
|