Добавим к лабиринту тихую мелодию и короткий сигнал при подборе ключа. Звук включается по желанию, останавливается на паузе и не мешает играть, если браузер отказал в воспроизведении. Настройка сохраняется между посещениями.

Открыть игру отдельно

Начнём с короткого звука по кнопке

Понадобятся готовый лабиринт с загрузкой рисунка, async/await из прошлого урока и безопасная работа с localStorage из урока 20. Папка start — финал прошлого урока со всеми файлами. Сначала открой её через локальный сервер и проверь игру без звука.

В комплекте два небольших WAV-файла, созданных для урока: key.wav — короткая последовательность нот, music.wav — повторяющаяся мелодия. Скопируй их в assets. WAV хранит звук; размер у него обычно больше, чем у сжатого MP3. Для нескольких секунд учебного примера этого достаточно. Внешние загрузки и лицензии на чужие записи здесь не нужны.

Добавь перед закрывающим main аудиоэлемент и отдельное сообщение. Кнопку помести в controls рядом с «Сначала»:

<button id="sound-button" type="button">Проверить эффект</button>
<audio id="effect" src="assets/key.wav" preload="auto"></audio>
<p id="audio-status" role="status">Нажми кнопку, чтобы проверить звук.</p>

audio — элемент для звука, src задаёт путь. Без атрибута controls стандартного проигрывателя не видно: им управляет наша кнопка. preload="auto" подсказывает браузеру заранее получить данные, но не запускает воспроизведение и не гарантирует мгновенную готовность.

В конце game.js до вызова resetGame() добавь:

const effect = document.querySelector("#effect");
const soundButton = document.querySelector("#sound-button");
const audioStatus = document.querySelector("#audio-status");
effect.volume = 0.3;
async function testSound() {
  effect.currentTime = 0;
  try {
    await effect.play();
    audioStatus.textContent = "Короткий эффект запущен.";
  } catch (error) {
    audioStatus.textContent = "Звук недоступен. Проверь путь к assets/key.wav.";
  }
}
soundButton.addEventListener("click", testSound);

volume принимает число от 0 до 1: 0 — тишина, 1 — полная громкость элемента. Устройство может дополнительно ограничивать её. currentTime — позиция в секундах; ноль возвращает короткий сигнал к началу. Метод play() возвращает Promise: он может выполниться позже или отклониться. Поэтому сообщение об успехе идёт после await, а отказ имеет свой catch.

Это step-01. Нажми кнопку мышью, затем выбери её клавишей Tab и нажми Enter. Наведение мыши не должно запускать звук. Проверь 404, временно изменив key.wav в src. Ошибка должна появиться в сообщении, игра — остаться рабочей. Исправь путь.

Музыка играет только во время партии

Браузер ограничивает автоматический запуск звука. Сохранённая настройка «включён» сама по себе не является новым действием игрока. Мы вызываем play из обработчика кнопки звука или старта, а при открытии страницы только читаем настройку. Даже после клика возможен отказ — например, из-за формата, запрета или потерянного файла.

Добавь второй элемент:

<audio id="music" src="assets/music.wav" loop preload="none"></audio>

loop повторяет запись после конца. preload="none" предлагает не загружать фоновую музыку заранее. В JavaScript выбери music, назначь ему volume = 0.12, а effect оставь 0.3. Замени testSound и её регистрацию функциями следующего шага; старый обработчик оставлять нельзя, иначе один клик запустит два действия.

Нужны soundEnabled — желание игрока, и audioRequest — номер текущей попытки воспроизведения. Это разные данные: даже при soundEnabled = true файл может не проигрываться. Поменяем кнопку на переключатель с aria-pressed, чтобы её состояние было доступно вспомогательным программам:

let soundEnabled = false;
let audioRequest = 0;
function updateSoundButton() {
  soundButton.textContent = soundEnabled ? "Выключить звук" : "Включить звук";
  soundButton.setAttribute("aria-pressed", String(soundEnabled));
}

Метод pause() останавливает элемент на текущей позиции. Для фоновой музыки позицию сохраняем: после продолжения она не обязана начинаться заново. У короткого эффекта возвращаем ноль:

function stopAudio() {
  audioRequest = audioRequest + 1;
  music.pause();
  effect.pause();
  effect.currentTime = 0;
}

Увеличение audioRequest делает результаты предыдущих ожиданий устаревшими. Например, play ещё ждёт, а игрок уже выключил звук. Позднее завершение не должно заменить «Звук выключен» на старое сообщение об успехе или ошибке.

async function playMusic() {
  if (!soundEnabled || state !== "playing") return;
  const request = audioRequest;
  try {
    await music.play();
    // За время ожидания игрок мог нажать паузу или выключить звук.
    if (!soundEnabled || state !== "playing") music.pause();
    else if (request === audioRequest) audioStatus.textContent = "Звук включён.";
  } catch (error) {
    if (request === audioRequest && soundEnabled && state === "playing") {
      audioStatus.textContent = "Музыка недоступна. Можно продолжить без звука.";
    }
  }
}

Сначала проверяем разрешение и игровое состояние. Затем сохраняем номер request. После await проверяем состояние ещё раз: за время ожидания оно могло поменяться. Сообщение обновляем, только если номер всё ещё текущий. В catch действует та же проверка. Здесь обработка асинхронного результата связана с жизнью игры, а не только с одним файлом.

Эффект устроен так же, но перед каждым проигрыванием возвращается к началу:

async function playEffect() {
  if (!soundEnabled || state !== "playing") return;
  const request = audioRequest;
  effect.currentTime = 0;
  try {
    await effect.play();
    if (!soundEnabled || state !== "playing") effect.pause();
  } catch (error) {
    if (request === audioRequest && soundEnabled && state === "playing") {
      audioStatus.textContent = "Эффект недоступен. Правила игры работают без звука.";
    }
  }
}

Подключи эти функции к уже существующим переходам:

  • В начало stopLoop добавь stopAudio();. Этим путём проходят пауза, смена вкладки, победа и сброс.
  • В handlePlay сразу после state = "playing"; добавь playMusic();. Не ставь перед ним ожидание загрузки: вызов должен происходить в обработчике действия игрока.
  • В checkObjects сразу после hasKey = true; добавь playEffect();. Ключ уже засчитан; ошибка аудио не отменяет правило.

Обработчик переключения для этого шага:

function handleSound() {
  soundEnabled = !soundEnabled;
  updateSoundButton();
  if (soundEnabled) {
    audioStatus.textContent = "Звук разрешён. Музыка играет только во время игры.";
    playMusic();
  } else {
    stopAudio();
    audioStatus.textContent = "Звук выключен.";
  }
}
function keepGameFocus(event) {
  event.preventDefault();
}
soundButton.addEventListener("mousedown", keepGameFocus);
soundButton.addEventListener("click", handleSound);
updateSoundButton();

mousedown приходит при нажатии кнопки мыши, раньше click. Только на переключателе звука отменяем стандартное перемещение фокуса: иначе щелчок увёл бы фокус с Canvas и поставил игру на паузу. Сам click остаётся. При переходе клавишей Tab фокус меняется нормально, игра останавливается; включи звук и затем нажми «Продолжить». На следующем уроке объединим мышь и касания через Pointer Events.

Собранный результат — step-02. Звук поначалу выключен. Начни игру, включи его, возьми ключ, нажми паузу. Музыка и эффект останавливаются. После «Продолжить» музыка снова запускается. Если звук выключен, ни продолжение, ни подбор ключа не должны его вернуть.

Запомним настройку, но не запустим звук автоматически

Для проверки сохранения открой игру отдельно по ссылке под демонстрацией. Встроенный пример изолирован от страницы: localStorage в нём может быть недоступен. В таком случае настройка действует до перезагрузки, а отказ показывается сообщением.

Для final добавь функции работы с хранилищем перед первым resetGame. Оставляем только один собственный ключ, чужие сохранения не очищаем:

const soundPreferenceKey = "codeindie.maze.sound";
function readSoundPreference() {
  try {
    soundEnabled = localStorage.getItem(soundPreferenceKey) === "on";
    if (soundEnabled) audioStatus.textContent = "Звук разрешён. Нажми «Начать», чтобы играть.";
  } catch (error) {
    audioStatus.textContent = "Настройку не удалось прочитать. Звук пока выключен.";
  }
  updateSoundButton();
}
function saveSoundPreference() {
  try {
    localStorage.setItem(soundPreferenceKey, soundEnabled ? "on" : "off");
  } catch (error) {
    audioStatus.textContent = "Настройка действует сейчас, но не сохранилась в браузере.";
  }
}
readSoundPreference();

В конце handleSound добавь saveSoundPreference();. Строгое сравнение с "on" означает, что отсутствующее или неожиданное значение не включает звук. При ошибке чтения действует начальное false. Ошибка записи не отменяет выбор текущей сессии: кнопка и звук продолжают работать, сообщение предупреждает только о сохранении.

Важно разместить все const для audio и ключа хранилища до первого resetGame(): он вызывает stopLoop, а теперь и stopAudio. Функцию можно объявить ниже вызова, но значение const должно быть создано до того, как выполняющийся код его прочитает. Готовые файлы final показывают этот порядок.

Включи звук, обнови страницу: настройка прочитана, но музыка молчит до «Начать». Выключи и обнови снова: кнопка предлагает включение. В broken-audio оба пути намеренно неверные; можно пройти игру и без них.

Самостоятельно: озвучь другое событие

Добавь отдельный короткий эффект для кристалла. Он должен играть один раз при его подборе, учитывать выключение звука и паузу и не повторяться каждый кадр. Используй crystal.wav из папки решения или свой файл, которым вправе пользоваться. Затем проверь неверный путь: очко всё равно должно начислиться.

До кода найди место, где collected меняется с false на true. Именно там происходит событие. Не добавляй play в render и не связывай его с каждым пересечением без проверки collected.

Подсказка 1: отдельный элемент

Если эффект использует тот же audio, что и ключ, currentTime будет прерывать другой сигнал. Для этого задания дай кристаллу отдельный элемент, переменную и небольшую функцию с try/catch.

Подсказка 2: выключение касается всех звуков

Допиши stopAudio: новый эффект тоже pause и currentTime = 0. После await повтори проверку soundEnabled/state и номера request.

Разбор после попытки

В solutions/crystal-sound вызов стоит внутри одноразовой ветки collectItems, после начисления очка. playCrystal только запускает звук: очко уже начислено. Попробуй изменить высоту или длительность собственного эффекта и проверь, не мешает ли он слышать ключ.

Проверим включение, паузу и ошибки

ДействиеОжидаемый результат
Открыть страницу с сохранённым "on"До действия музыка не запускается
Начать и выключить звукВсе audio остановлены, игра продолжается
Уйти в другую вкладкуМир и музыка на паузе; возвращение требует продолжения
Выключить сразу после включенияПоздний результат play не возвращает звук или старое сообщение
Сломать srcПонятный отказ, ключ и дверь работают
Хранилище недоступноВыбор действует в текущей сессии

Проверяй с небольшой громкостью. Успешное завершение play() означает, что браузер начал воспроизведение, но не доказывает, что на устройстве включены динамики. В своей проверке отдельно отметь: видел состояние элемента или действительно услышал запись.

Дополнительно: небольшая CSS-анимация интерфейса

Звук можно дополнить короткой анимацией декоративной подписи, не меняя координаты игрока и Canvas. Учитывай предпочтение уменьшенного движения; анимация не должна быть единственным сигналом события.

@media (prefers-reduced-motion: no-preference) {
  .sound-hint {
    animation: hint-pulse 0.4s ease-out;
  }
}

@keyframes hint-pulse {
  50% {
    transform: translateY(-2px);
  }
}

Справочно: элемент audio, результат play() и ограничения автоматического воспроизведения на MDN.