Звук и музыка в игре

Цыплёнок уже бегает и прыгает молча — пора, чтобы прыжок «пукал», монетка звенела, а на фоне крутился бодрый трек.

Спрайт рисует, как игра выглядит, а звук решает, как она ощущается. Один и тот же прыжок с весёлым «бойнг» и без него — это две разные игры.

Закрой глаза и вспомни любую игру, в которую ты залипал. Mario без «дзынь» от монетки? Geometry Dash без музыки, под которую ты подгадываешь прыжки? Among Us без того самого тревожного звука репорта? Звук — это половина кайфа, хотя его почти никогда не замечаешь специально. Замечаешь только когда он пропал: выключи звук в любимой игре и она сразу станет какой-то картонной, будто немое кино.

В прошлый раз ты добивал платформер: цыплёнок собирал монетки и убегал от врагов — мы делали это в уроке про предметы и врагов. Всё двигалось, всё сталкивалось, но было гробовое молчание. Сегодня мы дадим цыплёнку голос: научимся проигрывать короткие звуковые эффекты на прыжок и сбор монетки, запустим зацикленную фоновую музыку и сделаем кнопку «выключить звук», чтобы никого не бесить ночью в наушниках.

Что мы получим в конце

К концу урока у тебя будет вот такая картина: цыплёнок прыгает — слышен короткий «бойнг»; влетает в монетку — звенит «дзынь»; на фоне без остановки крутится трек, который начинается не сразу при загрузке, а после первого клика игрока. И будет кнопка с иконкой динамика, которая глушит вообще всё. Это полный звуковой набор для маленькой аркады.

Хорошая новость: звук в браузере устроен проще, чем рисование на canvas. Никаких контекстов и циклов — буквально «создал объект, сказал play()». Плохая новость: у браузера есть одно вредное правило, из-за которого музыка молчит у новичков. Про него отдельно поговорим, потому что на нём спотыкаются вообще все.

Звук как плеер в кармане

Представь, что у каждого звука в твоей игре есть свой маленький MP3-плеер. Ты заранее загрузил в него один трек, и теперь можешь в любой момент нажать «play» — плеер мгновенно проиграет свой звук. Хочешь, чтобы прыжок звучал — заводишь плеер с файлом «boing.mp3». Хочешь монетку — отдельный плеер с «coin.mp3». В JavaScript такой плеер называется объект Audio.

Создаётся он буквально одной строкой:

const jumpSound = new Audio('sounds/jump.mp3');
jumpSound.play();

Результат: браузер подгружает файл sounds/jump.mp3 и сразу его проигрывает — ты слышишь короткий звук прыжка из динамиков.

Вот и весь базовый секрет. new Audio('путь') — это «купить плеер и вставить в него трек», а .play() — «нажать кнопку воспроизведения». Файлы лучше брать в формате .mp3 или .ogg — их понимают все браузеры. Короткие эффекты можно нарезать самому в любом аудиоредакторе или взять бесплатные с сайтов вроде freesound — главное, чтобы эффект был коротким, на доли секунды, иначе игра превратится в кашу из звуков.

Полезно сразу понять, в чём разница между двумя типами звука, которые мы будем делать. Есть эффекты — короткие, резкие, привязанные к конкретному событию: прыжок, монетка, удар. Они длятся доли секунды и срабатывают столько раз, сколько ты прыгнул. И есть фоновая музыка — длинный трек, который играет всё время и крутится по кругу. Технически и то и другое — один и тот же объект Audio, просто настроенный по-разному. Эффект ты дёргаешь по событию и перематываешь в начало, а музыку запускаешь один раз и зацикливаешь. Держи это различие в голове — оно объясняет, почему дальше мы пишем для них немного разный код.

Ещё одна важная привычка: все плееры создавай один раз, в самом начале программы, рядом с остальными переменными. Создание new Audio() — это поход в файл за дорожкой, операция не бесплатная. Если делать её каждый раз заново, игра будет дёргаться. Создал плееры на старте — и потом весь урок только дёргаешь у них play(), pause() и крутишь свойства. Это та же логика, что и со спрайтами: картинку героя ты тоже грузишь один раз, а не перед каждым кадром.

Пример 1. Звук прыжка цыплёнка

У нас уже есть цыплёнок, который прыгает: при нажатии пробела мы добавляли ему вертикальную скорость против гравитации. Найдём то самое место в коде, где начинается прыжок, и подвесим туда звук. Сначала где-то наверху, рядом с остальными переменными, заведём наш плеер:

const chicken = { x: 60, y: 200, vx: 0, vy: 0, onGround: true };

// плееры со звуками — создаём один раз при старте
const jumpSound = new Audio('sounds/jump.mp3');

function jump() {
  if (chicken.onGround) {
    chicken.vy = -12;          // толкаем цыплёнка вверх
    chicken.onGround = false;
    jumpSound.currentTime = 0; // перематываем звук в начало
    jumpSound.play();          // и проигрываем
  }
}

Результат: каждый раз, когда цыплёнок отрывается от земли, ты слышишь короткий «бойнг». Если зажать пробел и быстро прыгать, звук исправно срабатывает на каждый прыжок.

Зачем строка currentTime = 0

Это самая хитрая деталь, и без неё новички ломают голову. Объект Audio — это один плеер. Если ты нажал play(), а звук ещё доигрывает прошлый прыжок, второй play() просто ничего не сделает — плеер уже занят. Поэтому перед каждым проигрыванием мы вручную перематываем дорожку в самое начало через jumpSound.currentTime = 0. Это как нажать «стоп, в начало, play» одним движением — звук всегда стартует заново, даже если ты дубасишь по пробелу как сумасшедший.

Пример 2. Звон монетки

С монеткой та же история, но звук вешаем в момент коллизии. Помнишь проверку AABB из платформера — когда прямоугольник цыплёнка пересекается с прямоугольником монетки? Вот прямо туда:

const coinSound = new Audio('sounds/coin.mp3');

function collectCoins() {
  for (const coin of coins) {
    if (!coin.collected && hitAABB(chicken, coin)) {
      coin.collected = true;     // монетка исчезает
      score += 1;                // плюс очко
      coinSound.currentTime = 0;
      coinSound.play();          // дзынь!
    }
  }
}

Результат: цыплёнок влетает в монетку — она пропадает, счёт растёт на единицу, и одновременно звенит «дзынь». Собираешь пять монеток подряд — слышишь пять чётких звоночков.

Обрати внимание: звук стоит ровно в той же ветке if, что и начисление очка. Это правильно — звук должен совпадать с тем, что видит игрок. Если повесить coinSound.play() снаружи проверки, он будет пиликать каждый кадр без остановки, 60 раз в секунду. Звук — это реакция на событие, а не фоновый шум.

Пример 3. Фоновая музыка по кругу

Эффекты — это короткие «дзынь» на событие. А музыка должна играть постоянно и сама перезапускаться, когда трек кончился. Для этого у объекта Audio есть два полезных свойства: loop и volume.

const music = new Audio('sounds/theme.mp3');
music.loop = true;     // зациклить: кончился трек — играет заново
music.volume = 0.4;    // громкость от 0 (тишина) до 1 (на всю)

function startMusic() {
  music.play();
}

Результат: как только вызывается startMusic(), трек начинает играть на 40% громкости и крутится по кругу без пауз — дошёл до конца и тут же стартует с начала.

loop = true — это галочка «повторять» в любом плеере. volume = 0.4 — ползунок громкости, где 1 это максимум, а 0 — полная тишина. Музыку почти всегда стоит делать тише эффектов: если трек гремит на единице, он заглушит «дзынь» от монетки, и игрок перестанет слышать обратную связь. Сорок процентов — комфортный старт, дальше подкрутишь на слух.

Главная грабля: браузер не даёт играть до клика

А теперь самое важное, из-за чего музыка молчит у каждого второго новичка. Ты пишешь music.play() прямо при загрузке страницы, открываешь — и тишина. В консоли вылезает что-то вроде play() failed because the user didn't interact with the document first. Кода нет ошибки — это сам браузер тебя блокирует.

Браузеры специально запрещают сайтам запускать звук до того, как игрок что-нибудь нажал на странице.

Логика простая и, если честно, добрая. Представь, что любой сайт мог бы заорать музыкой, едва ты его открыл — ты бы поседел, открывая 20 вкладок в тишине ночью. Поэтому есть правило: автозапуск звука со страницы запрещён, пока пользователь сам не кликнул, не нажал клавишу или не тапнул по экрану. Только после первого «контакта» браузер считает, что игрок реально пришёл в игру, и разрешает звук.

Решение — не запускать музыку сразу, а дождаться первого действия игрока. Обычно это и так происходит: в игре есть кнопка «Старт» или клик по холсту. Вешаем запуск музыки туда:

const music = new Audio('sounds/theme.mp3');
music.loop = true;
music.volume = 0.4;

let musicStarted = false;

// запускаем музыку при первом клике по странице
document.addEventListener('click', () => {
  if (!musicStarted) {
    music.play();
    musicStarted = true;   // больше не запускаем
  }
});

Результат: при загрузке тихо, но как только игрок щёлкнул мышкой хоть раз — фоновый трек заводится и дальше крутится по кругу. Флаг musicStarted следит, чтобы при каждом следующем клике музыка не запускалась заново поверх самой себя.

Разберём, что тут происходит, по шагам. addEventListener('click', ...) — это «подпишись на каждый клик по странице и каждый раз вызывай вот эту функцию». Внутри функции мы первым делом проверяем флаг: если музыка ещё не запускалась (!musicStarted), запускаем её и сразу ставим флаг в true. Со второго клика условие уже не сработает, и музыка спокойно играет себе дальше. Без этого флага каждый твой клик дёргал бы play() заново — и хотя браузер не плодит копий одного плеера, выглядит это неаккуратно, а на эффектах такой подход вообще привёл бы к наложению звуков.

Кстати, эта же первая «разблокировка» открывает дорогу всем звукам, а не только музыке. Браузер запоминает, что игрок уже взаимодействовал со страницей, и дальше эффекты прыжка и монетки проигрываются без проблем. Поэтому если ты вешаешь звуки на клавиши (как прыжок на пробел), они обычно работают сразу — нажатие клавиши само по себе считается взаимодействием. А вот музыка, которую ты хочешь завести до любого действия игрока, упрётся в запрет. Отсюда вывод: спрячь старт музыки за первый клик или за кнопку «Играть», и проблема исчезнет навсегда.

Пример 4. Кнопка «выключить звук»

Любой нормальный игрок рано или поздно захочет приглушить игру — он на уроке, рядом спит кот или он просто слушает свой плейлист. У объекта Audio для этого есть свойство muted. Заведём одну переменную-флаг и будем переключать её, а заодно глушить все наши плееры разом:

let soundOn = true;
const allSounds = [jumpSound, coinSound, music];

function toggleSound() {
  soundOn = !soundOn;                  // переключаем вкл/выкл
  for (const s of allSounds) {
    s.muted = !soundOn;                // muted = true — тишина
  }
}

// рисуем иконку динамика в углу холста
function drawSoundIcon(ctx) {
  ctx.font = '28px sans-serif';
  ctx.fillText(soundOn ? '\uD83D\uDD0A' : '\uD83D\uDD07', 460, 36);
}

Результат: в углу холста рисуется иконка динамика. По вызову toggleSound() все звуки разом замолкают, а иконка меняется на перечёркнутый динамик; повторный вызов возвращает звук обратно.

Мы собрали все плееры в массив allSounds и пробегаем по нему циклом — так не нужно вручную трогать каждый звук, а если завтра добавишь звук врага, просто допишешь его в массив. muted = true заглушает плеер мгновенно, при этом музыка не останавливается, а продолжает крутиться «беззвучно» — снимешь mute, и трек подхватится с того места, где играл бы.

Иконку мы рисуем прямо на canvas через ctx.fillText, используя символы-эмодзи динамика. Тернарный оператор soundOn ? '...' : '...' читается как «если звук включён — нарисуй обычный динамик, иначе — перечёркнутый». Это короткая запись для «выбрать одно из двух в зависимости от условия». Чтобы кнопка реагировала на клик, тебе останется в обработчике клика проверить, попал ли курсор в правый верхний угол холста, где нарисована иконка, и если да — вызвать toggleSound(). Координаты клика по холсту мы уже умели считать в уроке про управление мышью, так что это знакомая задача.

Почему именно muted, а не volume = 0? Можно и так, разница тонкая, но важная: muted — это отдельный выключатель, который не трогает выставленную громкость. Заглушил, потом снял mute — и музыка снова на своих 40%, как ты и настраивал. А если глушить через volume = 0, тебе придётся где-то запоминать прежнее значение, чтобы вернуть его обратно. Для кнопки «вкл/выкл звук» muted просто удобнее.

Частые ошибки и подводные камни

  • Музыка молчит, в консоли ошибка про autoplay. Ты запускаешь звук до клика игрока. Перенеси play() в обработчик клика или нажатия кнопки «Старт» — это правило браузера, обойти его «в лоб» нельзя.
  • Один и тот же звук не срабатывает при быстром повторе. Плеер ещё доигрывает прошлый раз. Добавь звук.currentTime = 0 перед play(), чтобы перематывать дорожку в начало.
  • Звук пиликает 60 раз в секунду. Ты поставил play() внутри игрового цикла или снаружи проверки if. Звук должен срабатывать на событие (прыжок, коллизия), а не каждый кадр.
  • Создаёшь new Audio(...) внутри функции, которая зовётся каждый кадр. Тогда каждый кадр рождается новый плеер, память забивается, игра тормозит. Создавай объекты Audio один раз при старте, рядом с остальными переменными, и переиспользуй.
  • Тишина и в консоли пусто. Проверь путь к файлу: 'sounds/jump.mp3' должен реально существовать рядом со страницей. Открой вкладку Network в инструментах разработчика — если файл красный с кодом 404, путь неверный.

Мини-практика: озвучь свою аркаду

Теперь твоя очередь. Возьми платформер с прошлого урока и доведи звук до ума:

  1. Добавь звук на приземление цыплёнка — короткий «туп», когда onGround снова становится true после полёта.
  2. Сделай отдельный звук столкновения с врагом — что-нибудь тревожное, чтобы игрок сразу понял: что-то пошло не так.
  3. Добавь второй трек — музыку проигрыша. Когда игра заканчивается, останови фоновую музыку через music.pause() и проиграй грустный джингл один раз (без loop).
  4. Сделай так, чтобы кнопка звука реагировала на клавишу, например M — повесь toggleSound() на нажатие этой клавиши.

Подсказка для пункта 3: чтобы переключиться с фоновой музыки на джингл, сначала music.pause(), потом loseSound.play(). Не забудь, что джингл проигрыша зацикливать не надо — loop у него оставь по умолчанию false.

Итоги

Ты дал цыплёнку голос. Теперь ты умеешь главное про звук в браузерных играх: создавать плеер через new Audio() и проигрывать его на событие, перематывать дорожку через currentTime = 0 для быстрых повторов, зацикливать музыку через loop и крутить громкость через volume. И, что важнее всего, ты знаешь про вредное правило браузера — звук стартует только после первого клика игрока, и теперь это правило тебя не застанет врасплох.

Эти куски кода — отдельные плееры, флаг soundOn, иконка динамика — мы заберём с собой дальше. В следующих уроках раздела графики мы займёмся тем, чтобы игра не только звучала, но и красиво вспыхивала: добавим частицы — облачка пыли под ногами цыплёнка и брызги искр, когда он хватает монетку. Звук плюс частицы — и твоя простая аркада начнёт ощущаться как настоящая игра.

Проверьте себя
1. Почему музыка не начинает играть сразу при загрузке страницы, даже если код с music.play() стоит в самом начале?
AБраузер запрещает автозапуск звука, пока игрок сам что-нибудь не нажмёт на странице
BОбъект Audio загружается слишком медленно и не успевает к старту
CСвойство loop отключает воспроизведение до первого цикла
DJavaScript не умеет проигрывать звук без библиотеки
2. Зачем перед jumpSound.play() писать jumpSound.currentTime = 0?
AЧтобы перемотать дорожку в начало и звук срабатывал даже при быстром повторе
BЧтобы выключить звук на время прыжка
CЧтобы установить громкость на ноль
DЧтобы зациклить звук прыжка
3. Какое значение volume сделает фоновую музыку тише, чтобы она не заглушала эффекты?
Avolume = 0.4 (около 40% громкости)
Bvolume = 1 (максимум)
Cvolume = 2 (двойная громкость)
Dvolume = -1 (отрицательная)
4. Где правильно вызывать coinSound.play() при сборе монетки?
AВнутри ветки if, где проверяется коллизия и начисляется очко
BВ игровом цикле каждый кадр, без всяких условий
CОдин раз при загрузке страницы рядом с new Audio
DВ функции рисования кадра, после ctx.fillText
5. Что делает свойство muted = true у объекта Audio?
AМгновенно заглушает плеер, при этом музыка продолжает крутиться беззвучно
BПолностью останавливает и удаляет объект Audio из памяти
CЗапускает звук заново с начала дорожки
DВключает зацикливание трека
6. Почему нельзя писать new Audio('sounds/jump.mp3') внутри функции, которая вызывается каждый кадр?
AКаждый кадр будет рождаться новый плеер, память забьётся и игра начнёт тормозить
BБраузер запрещает создавать больше одного объекта Audio
CЗвук станет слишком громким из-за наложения
DФайл mp3 нельзя загружать дважды за сессию