From 4532e4a66ce03a1b43ccb2c44f4a369f26e70ced Mon Sep 17 00:00:00 2001 From: Sebastian Szczepanski Date: Fri, 1 Aug 2025 17:24:25 +0200 Subject: [PATCH] voice --- index.html | 5 +- src/audio.js | 87 +++++++++++++++++++--------- src/game.js | 18 ++++-- src/maze.js | 158 ++++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 214 insertions(+), 54 deletions(-) diff --git a/index.html b/index.html index 7a3d0b0..1d0cff8 100644 --- a/index.html +++ b/index.html @@ -13,12 +13,13 @@

Use WASD or Arrow Keys to move

⚠️ Red squares are deadly - avoid them!

-

🔊 Hidden dangers end the game - listen carefully!

-

🎧 Use headphones for best directional audio experience

+

🔊 Hidden dangers end the game - listen for directions!

+

🗣️ Speech directions: "left", "right", "up", "down"

+
diff --git a/src/audio.js b/src/audio.js index c02c4dc..3090314 100644 --- a/src/audio.js +++ b/src/audio.js @@ -4,6 +4,8 @@ export class AudioSystem { this.gainNode = null; this.activeSounds = new Set(); this.initialized = false; + this.lastSpokenDirection = ''; + this.speechEnabled = true; this.initializeAudio(); } @@ -127,39 +129,68 @@ export class AudioSystem { } async playDirectionalProximitySound(directions) { - await this.ensureAudioContext(); - - // Calculate the dominant direction for panning - let panValue = 0; - let pitchModifier = 0; - - // Horizontal panning (left/right) - if (directions.left && !directions.right) { - panValue = -0.7; // Pan left - } else if (directions.right && !directions.left) { - panValue = 0.7; // Pan right + // Use text-to-speech instead of sounds + this.speakDirections(directions); + } + + speakDirections(directions) { + if (!this.speechEnabled || !('speechSynthesis' in window)) { + return; } + + // Determine which directions to announce + const activeDirections = []; - // Vertical pitch variation (up/down) - if (directions.up && !directions.down) { - pitchModifier = 50; // Higher pitch for above - } else if (directions.down && !directions.up) { - pitchModifier = -50; // Lower pitch for below + if (directions.up) activeDirections.push('up'); + if (directions.down) activeDirections.push('down'); + if (directions.left) activeDirections.push('left'); + if (directions.right) activeDirections.push('right'); + + if (activeDirections.length === 0) { + return; } + + // Create the text to speak + let textToSpeak; + if (activeDirections.length === 1) { + textToSpeak = activeDirections[0]; + } else if (activeDirections.length === 2) { + textToSpeak = activeDirections.join(' and '); + } else { + textToSpeak = activeDirections.slice(0, -1).join(', ') + ', and ' + activeDirections[activeDirections.length - 1]; + } + + // Avoid repeating the same direction announcement + if (textToSpeak === this.lastSpokenDirection) { + return; + } + this.lastSpokenDirection = textToSpeak; + + // Stop any currently playing speech + speechSynthesis.cancel(); + + // Create and configure the speech utterance + const utterance = new SpeechSynthesisUtterance(textToSpeak); + utterance.rate = 1.2; // Slightly faster speech + utterance.pitch = 1.0; + utterance.volume = 0.8; - // Create directional warning sounds - this.createOscillator(300, 'sine', 0.15, panValue, pitchModifier); - this.createOscillator(250, 'sine', 0.15, panValue, pitchModifier * 0.5); - - // Add subtle rhythmic variation based on direction - if (directions.up) { - // Quick double beep for above + // Clear the last spoken direction when speech ends + utterance.onend = () => { setTimeout(() => { - this.createOscillator(350, 'sine', 0.05, panValue, pitchModifier); - }, 80); - } else if (directions.down) { - // Slower, deeper sound for below - this.createOscillator(200, 'sine', 0.2, panValue, pitchModifier); + this.lastSpokenDirection = ''; + }, 500); // Small delay to prevent immediate repetition + }; + + // Speak the directions + speechSynthesis.speak(utterance); + } + + setSpeechEnabled(enabled) { + this.speechEnabled = enabled; + if (!enabled) { + speechSynthesis.cancel(); + this.lastSpokenDirection = ''; } } diff --git a/src/game.js b/src/game.js index bd4e7a4..21233e5 100644 --- a/src/game.js +++ b/src/game.js @@ -23,6 +23,7 @@ export class Game { // Game state this.soundEnabled = true; + this.speechEnabled = true; this.vibrationEnabled = true; this.lastProximityWarning = 0; @@ -32,6 +33,7 @@ export class Game { setupControls() { const soundToggle = document.getElementById('soundToggle'); + const speechToggle = document.getElementById('speechToggle'); const vibrationToggle = document.getElementById('vibrationToggle'); soundToggle.addEventListener('click', () => { @@ -41,6 +43,12 @@ export class Game { this.audioSystem.stopAll(); } }); + + speechToggle.addEventListener('click', () => { + this.speechEnabled = !this.speechEnabled; + speechToggle.textContent = `🗣️ Speech: ${this.speechEnabled ? 'ON' : 'OFF'}`; + this.audioSystem.setSpeechEnabled(this.speechEnabled); + }); vibrationToggle.addEventListener('click', () => { this.vibrationEnabled = !this.vibrationEnabled; @@ -127,20 +135,20 @@ export class Game { update(deltaTime) { this.player.update(deltaTime); - // Check proximity to audio danger squares and play directional warning sounds - if (this.soundEnabled) { + // Check proximity to audio danger squares and play directional warning speech + if (this.speechEnabled) { const directions = this.maze.getAudioDangerDirections(this.player.x, this.player.y); if (directions.squares.length > 0) { this.playDirectionalProximityWarning(directions); } } } - + playDirectionalProximityWarning(directions) { - // Play a directional warning sound when near audio danger squares + // Play a directional warning speech when near audio danger squares // Use a timer to avoid playing too frequently const now = Date.now(); - if (!this.lastProximityWarning || now - this.lastProximityWarning > 1000) { + if (!this.lastProximityWarning || now - this.lastProximityWarning > 1500) { this.audioSystem.playDirectionalProximitySound(directions); this.lastProximityWarning = now; } diff --git a/src/maze.js b/src/maze.js index d830533..7e961e3 100644 --- a/src/maze.js +++ b/src/maze.js @@ -23,14 +23,13 @@ export class Maze { // Create outer boundary walls only this.createOuterWalls(); - // Add dangerous squares (about 30% of inner safe squares) - this.addDangerousSquares(); - // Set exit (ensure it's not on the outer wall) this.exitX = this.cols - 2; this.exitY = this.rows - 2; this.grid[this.exitY][this.exitX] = this.EXIT; - this.dangerousSquares.delete(`${this.exitX},${this.exitY}`); + + // Add dangerous squares while ensuring a path exists + this.addDangerousSquaresWithPathGuarantee(); } createOuterWalls() { @@ -77,6 +76,123 @@ export class Maze { this.dangerousSquares.add(`${x},${y}`); } } + + addDangerousSquaresWithPathGuarantee() { + const innerSafeSquares = []; + + // Find all inner safe squares (excluding outer wall boundary, player start, and exit) + for (let y = 1; y < this.rows - 1; y++) { + for (let x = 1; x < this.cols - 1; x++) { + if (this.grid[y][x] === this.SAFE && !(x === 1 && y === 1) && !(x === this.exitX && y === this.exitY)) { + innerSafeSquares.push({ x, y }); + } + } + } + + // Calculate target number of dangerous squares (30% of inner safe squares) + const totalDangerousCount = Math.floor(innerSafeSquares.length * 0.3); + const visualDangerousCount = Math.floor(totalDangerousCount * 0.5); + const audioDangerousCount = totalDangerousCount - visualDangerousCount; + + this.shuffle(innerSafeSquares); + + let addedDangerous = 0; + let addedVisual = 0; + let addedAudio = 0; + + // Add dangerous squares one by one, checking path after each addition + for (let i = 0; i < innerSafeSquares.length && addedDangerous < totalDangerousCount; i++) { + const { x, y } = innerSafeSquares[i]; + + // Determine what type of dangerous square to add + let dangerType; + if (addedVisual < visualDangerousCount) { + dangerType = this.DANGEROUS_VISUAL; + } else if (addedAudio < audioDangerousCount) { + dangerType = this.DANGEROUS_AUDIO; + } else { + break; // We've added enough dangerous squares + } + + // Temporarily place the dangerous square + const originalType = this.grid[y][x]; + this.grid[y][x] = dangerType; + + // Check if a path still exists from start to exit + if (this.hasPathToExit(1, 1)) { + // Path exists, keep this dangerous square + this.dangerousSquares.add(`${x},${y}`); + addedDangerous++; + if (dangerType === this.DANGEROUS_VISUAL) { + addedVisual++; + } else { + addedAudio++; + } + } else { + // Path blocked, revert this square to safe + this.grid[y][x] = originalType; + } + } + } + + // Breadth-first search to check if a safe path exists from start to exit + hasPathToExit(startX, startY) { + const visited = new Set(); + const queue = [{ x: startX, y: startY }]; + const targetKey = `${this.exitX},${this.exitY}`; + + while (queue.length > 0) { + const { x, y } = queue.shift(); + const key = `${x},${y}`; + + if (key === targetKey) { + return true; // Found path to exit + } + + if (visited.has(key)) { + continue; + } + visited.add(key); + + // Check all four directions + const directions = [ + { dx: 0, dy: -1 }, // up + { dx: 0, dy: 1 }, // down + { dx: -1, dy: 0 }, // left + { dx: 1, dy: 0 } // right + ]; + + for (const { dx, dy } of directions) { + const newX = x + dx; + const newY = y + dy; + const newKey = `${newX},${newY}`; + + // Check if the new position is valid and safe to walk + if (newX >= 0 && newX < this.cols && + newY >= 0 && newY < this.rows && + !visited.has(newKey) && + this.isSafeToWalk(newX, newY)) { + queue.push({ x: newX, y: newY }); + } + } + } + + return false; // No safe path found + } + + // Check if a position is safe to walk (only safe squares and exit) + isSafeToWalk(x, y) { + const cellType = this.grid[y][x]; + return cellType === this.SAFE || cellType === this.EXIT; + } + + // Check if a position is walkable (not a wall - for game movement) + isWalkable(x, y) { + const cellType = this.grid[y][x]; + // Player can walk on all squares except walls + // Dangerous squares end the game but are still walkable for movement + return cellType !== this.WALL; + } shuffle(array) { for (let i = array.length - 1; i > 0; i--) { @@ -89,7 +205,7 @@ export class Maze { if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) { return false; } - return this.grid[y][x] !== this.WALL; + return this.isWalkable(x, y); } isDangerous(x, y) { @@ -121,7 +237,7 @@ export class Maze { } getAudioDangerDirections(playerX, playerY, range = 1) { - // Get all directions where audio danger squares are located + // Get all directions where audio danger squares are located (cardinal directions only) const directions = { left: false, right: false, @@ -130,22 +246,23 @@ export class Maze { squares: [] }; - for (let dy = -range; dy <= range; dy++) { - for (let dx = -range; dx <= range; dx++) { - if (dx === 0 && dy === 0) continue; // Skip player's current position - - const checkX = playerX + dx; - const checkY = playerY + dy; + // Only check cardinal directions (no corners) + const cardinalDirections = [ + { dx: -1, dy: 0, dir: 'left' }, // left + { dx: 1, dy: 0, dir: 'right' }, // right + { dx: 0, dy: -1, dir: 'up' }, // up + { dx: 0, dy: 1, dir: 'down' } // down + ]; + + for (const { dx, dy, dir } of cardinalDirections) { + for (let dist = 1; dist <= range; dist++) { + const checkX = playerX + (dx * dist); + const checkY = playerY + (dy * dist); if (checkX >= 0 && checkX < this.cols && checkY >= 0 && checkY < this.rows) { if (this.grid[checkY][checkX] === this.DANGEROUS_AUDIO) { - directions.squares.push({ x: checkX, y: checkY, dx, dy }); - - // Determine primary directions - if (dx < 0) directions.left = true; - if (dx > 0) directions.right = true; - if (dy < 0) directions.up = true; - if (dy > 0) directions.down = true; + directions.squares.push({ x: checkX, y: checkY, dx: dx * dist, dy: dy * dist }); + directions[dir] = true; } } } @@ -183,6 +300,9 @@ export class Maze { ctx.lineWidth = 1; ctx.strokeRect(pixelX, pixelY, cellSize, cellSize); break; + //debug + // case this.DANGEROUS_AUDIO: + case this.DANGEROUS_VISUAL: // Visual-only danger squares - animated red appearance