У игры уже есть мир, камера, текст HUD и сцены, но она молчит, и молчание выдаёт в ней прототип. Прыжок без шороха, столкновение без удара, победа без фанфар — игрок считывает это как пустоту, даже если не может назвать причину. Ebitengine решает вопрос без сторонних библиотек: в поставке движка есть пакет audio, который даёт контекст вывода, плееры и декодеры форматов, и весь остальной звук — музыка, эффекты, интерфейсные щелчки — собирается из этих трёх деталей. Звук в играх несложен, пока вы понимаете два правила: контекст один, а ссылки на плееры живут столько, сколько играет звук. Нарушение первого рождает кашу из устройств вывода, нарушение второго — загадочно замолкающие эффекты. В этом уроке мы собираем аудиокаркас так, чтобы он пережил и долгую сессию, и итоговый проект.
audio.Context: один контекст на игру#
Аудиосистема начинается с audio.Context — объекта, который владеет устройством вывода и микширует все потоки игры. Контекст создаётся один раз за жизнь программы вызовом audio.NewContext(44100): аргумент — частота дискретизации в герцах, то есть сколько сэмплов в секунду потребляет выход. Дублей быть не должно, поэтому у контекста одно место рождения — конструктор Game; там, где контекст нужен без передачи через поля, готовый объект отдаёт audio.CurrentContext() — именно так поступают примеры самого движка. Частота выбирается один раз и навсегда: 44100 или 48000 герц, разницы на слух нет, зато все плееры контекста работают в одной шкале, и смешивать частоты вручную не придётся.
Второе, что важно понять: звук живёт вне игрового цикла. Update и Draw тикают шестьдесят раз в секунду, а аудиопоток потребляется в собственной ритмике движка: плеер сам читает данные из потока по мере проигрывания, и ваше дело — лишь не терять на него ссылки и подавать команды. Отсюда стиль аудиокода: короткие вызовы из игрового кода — играй, стоп, громкость, назад в начало, — и никакого покадрового обслуживания. Контекст, созданный в конструкторе, спокойно доживает до конца программы, а сцены и меню через него лишь меняют плееры. Полезное следствие — аудио не замедляет Update: код, играющий эффект, тратит микросекунды на поиск свободного плеера и запуск, само воспроизведение идёт параллельно кадрам, и даже сотня наложенных звуков не сдвигает ни один тик.
Контекст и зацикленная музыка
GO//go:embed assets/music.ogg
var musicOgg []byte
const (
sampleRate = 44100
bytesPerSample = 8 // 2 канала * 4 байта float32
introSeconds = 4
)
type Game struct {
audioCtx *audio.Context
music *audio.Player // ссылка обязана жить в структуре
musicVolume float64
}
func NewGame() (*Game, error) {
g := &Game{musicVolume: 0.6}
g.audioCtx = audio.NewContext(sampleRate) // один контекст на игру
s, err := vorbis.DecodeF32(bytes.NewReader(musicOgg)) // Ogg -> PCM
if err != nil {
return nil, err
}
// интро звучит один раз, дальше трек крутится по кругу
loop := audio.NewInfiniteLoopWithIntroF32(s,
introSeconds*bytesPerSample*sampleRate,
s.Length()-introSeconds*bytesPerSample*sampleRate)
g.music, err = g.audioCtx.NewPlayerF32(loop)
if err != nil {
return nil, err
}
g.music.SetVolume(g.musicVolume) // громкость 0..1
g.music.Play()
return g, nil
}Контекст создаётся в единственном месте — конструкторе игры. Декодер vorbis.DecodeF32 превращает Ogg-файл в поток float32, длину которого Length() отдаёт в байтах: для стерео F32 один сэмпл весит восемь байт, поэтому длительность интро умножается на частоту и bytesPerSample. Обёртка NewInfiniteLoopWithIntroF32 делает поток бесконечным, NewPlayerF32 получает плеер, SetVolume задаёт громкость от нуля до единицы, Play запускает музыку до явной остановки.
Сколько объектов audio.Context должно быть в игре?
Форматы: WAV, MP3 и Ogg#
Пакет audio сырые файлы не ест: байты превращаются в PCM-сэмплы декодерами из подпакетов. Их три: wav.DecodeF32 для WAV, mp3.DecodeF32 для MP3 и vorbis.DecodeF32 для Ogg с кодеком Vorbis — этот список форматов и есть поддержка движка, всё сверх него придётся декодировать сторонними библиотеками и скармливать плееру готовые сэмплы. Выбор формата — компромисс размера и скорости: WAV не сжимается и декодируется мгновенно, поэтому он идеален для коротких эффектов, которые запускаются в ответ на нажатие; Ogg и MP3 сжимают в разы лучше, поэтому ими берут музыку и длинные вставки, где лишняя задержка на старте незаметна. Декодеры отдают поток F32 — тридцатидвухбитные плавающие сэмплы, единый формат для всех плееров контекста, и суффикс F32 в именах декодеров об этом честно говорит.
Файлы, из которых всё это собирается, попадают в игру не с диска в момент запуска, а из байтов в памяти: их встраивает в бинарь директива go:embed, о которой ниже отдельный разговор и которую активно использует сборка проекта из урока про платформу. Отдельный вопрос — права на сами файлы: лицензии ассетов разбирает урок про ассеты, и аудио там стоит первым пунктом, потому что лицензия на трек способна запретить игру целиком.
PlaySfx: пул плееров и mute-тумблер
GO// в NewGame: декодируем эффект один раз и держим готовые байты
d, err := wav.DecodeF32(bytes.NewReader(jumpWav))
if err != nil {
return nil, err
}
g.sfxBytes, err = io.ReadAll(d) // PCM в памяти: плеер собирается мгновенно
// PlaySfx играет эффект: свободный плеер переиспользуется,
// иначе заводится новый
func (g *Game) PlaySfx() {
for _, p := range g.sfxPool {
if !p.IsPlaying() {
p.Rewind() // назад в начало
p.Play()
return
}
}
p, err := g.audioCtx.NewPlayerF32FromBytes(g.sfxBytes)
if err != nil {
return
}
g.sfxPool = append(g.sfxPool, p) // ссылка живёт в слайсе
p.SetVolume(0.5)
p.Play()
}
// в Update: mute по клавише M
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
g.muted = !g.muted
if g.muted {
g.music.SetVolume(0)
} else {
g.music.SetVolume(g.musicVolume)
}
}Байты эффекта декодируются один раз при загрузке: io.ReadAll вынимает из потока готовые PCM-сэмплы, и NewPlayerF32FromBytes потом собирает плеер за микросекунды. PlaySfx сначала ищет в пуле неиграющий плеер: Rewind возвращает его в начало, Play запускает заново. Свободных нет — создаётся новый и добавляется в слайс ссылок, поэтому эффекты накладываются друг на друга. Тумблер mute не удаляет звук, а ставит музыке громкость ноль: она продолжает играть, и снятие mute продолжит её с того же места.
Звук → плеер → громкость#
| Звук | Плеер | Громкость |
|---|---|---|
| Шаги и прыжок героя | один плеер в Game, Rewind и Play | 0.7–0.9: герой слышен всегда |
| Выстрелы и попадания | пул из PlaySfx, звуки поверх друг друга | 0.4–0.6: частое звучит тише |
| Музыка уровня | один долгий плеер с бесконечным лупом | 0.3–0.5: фон не перекрикивает бой |
| UI-клики | переиспользуемый плеер из пула | 0.3: короткий и ненавязчивый |
| Смерть и победа | отдельный плеер без зацикливания | 1.0: событие раз за партию |
| Mute-тумблер | плееры не трогает, меняет SetVolume | 0 у музыки, эффекты по вкусу |
Что услышит игрок при двух выстрелах в один тик?
Прочитайте код и выберите, что он выведет:
// пул пуст, ни один эффект не играет
PlaySfx() // -> NewPlayerF32FromBytes, играет плеер p1
// второй выстрел в тот же тик:
PlaySfx()Громкость: SetVolume и тумблер mute#
Громкость плеера — это SetVolume с числом от нуля до единицы: единица означает исходную громкость файла, ноль — тишину. Значения выше единицы усиления не обещают, а общий ползунок в настройках — это не новый механизм, а умножение базовых громкостей плееров на коэффициент. Ухо слышит громкость логарифмически: разница между 0.1 и 0.2 огромна, между 0.8 и 0.9 почти незаметна, поэтому линейный ползунок ощущается сломанным — первые шаги глухие, последние три оглушают. Практическое правило простое: музыку держать заметно тише эффектов, иначе она съедает выстрелы, шаги и всё, на чём строится отклик игры.
Mute — не отдельный тайник громкости, а запомненное значение: тумблер сохраняет текущую громкость в поле структуры, ставит ноль, а при снятии возвращает сохранённое. Место для muted и уровней громкости — игровое состояние: настройки звука живут рядом со счётом и паузой и обязаны переживать переходы сцен. Резкий SetVolume на границе смерти режет ухо, поэтому громкость, которую меняет сама игра, лучше подводить к цели за короткий таймер в Update — плавное затухание звучит профессиональнее щелчка.
Эффект играл три секунды и оборвался на середине. Его плеер создаётся локальной переменной и нигде не сохраняется. Что произошло?
Симптом коварный: на коротких тестах всё играет, а в долгой сессии эффекты начинают замолкать в случайных местах. Виноват сборщик мусора: локальный плеер из функции звучал ровно до того тика, когда GC до него добрался. В отладчике ловушка невидима — никто не паникует, звук просто заканчивается раньше файла. Дисциплина одна: каждый плеер, который обязан доиграть до конца, живёт в поле структуры или в слайсе пула, как music и sfxPool выше. Потеряли последнюю ссылку — считайте, эффект уже не сыграет, и никакой флаг не предупредит.
Эффекты и музыка: разные жизни плееров#
Короткий эффект и музыкальный трек — два разных режима работы плеера, и смешивать их — типовая ошибка. Эффект живёт секунды: его данные держат готовыми байтами в памяти, плеер берётся из пула или заводится на лету, и после Play ему не нужно никакого ухода. Музыка живёт всю сцену: это один долгий плеер, заведённый на входе в игру или уровень, с бесконечным лупом из NewInfiniteLoopWithIntroF32, которым управляют только пауза и громкость. Заводить новый плеер на каждый кадр музыки — прямой путь к исчерпанию ресурсов, а переиспользовать музыкальный плеер для эффектов — получить Rewind посреди трека вместо прыжка.
Правило звучит так: сколько независимых источников у события — столько и плееров. Прыжок героя играет через один плеер, который Rewind-ится на новом прыжке: старый звук обрывается, и это даже правильно — очередь прыжков не должна звучать хором. Выстрелы очереди накладываются — значит, нужен пул. Музыка одна, и второй плеер для неё заводится только при смене трека. Ровно так устроен пример flappy из поставки движка: два поля jumpPlayer и hitPlayer, на каждое событие — Rewind и Play, и никакой очереди плееров. Пауза музыки — тоже отдельная работа: уходя в меню, плеер ставят на Pause, возвращаясь в бой — на Play, и трек продолжается ровно с того же места, где игрок его покинул. Если музыка должна смениться, поле music просто заменяется новым плеером: старую ссылку никто не держит, и сборщик забирает плеер вместе с отзвучавшим треком.
Правила звука одним списком#
- audio.Context создаётся один раз на игру: плееры берутся только из него
- Ссылка на плеер живёт в поле структуры или в пуле — иначе GC остановит звук
- Декодеры движка — WAV, MP3 и Ogg: эффекты в WAV, музыка в сжатых форматах
- Эффекты декодируются один раз в байты, плееры собираются из NewPlayerF32FromBytes
- Короткие звуки — пул плееров с Rewind и Play: наложение звуков бесплатно
- Музыка — один долгий плеер с бесконечным лупом и паузой на выходе в меню
- Громкость — SetVolume от нуля до единицы, музыка тише эффектов
- Mute хранит прошлое значение в состоянии игры и возвращает его при снятии
Соберите запуск музыки
Восстановите два вызова: создание контекста и плеера.
Сам звук требует настоящего аудиовыхода и проверяется в локальном Go, но логика пула — чистые данные: слайс ссылок, флаг IsPlaying, поиск свободного плеера. Тематическая программа в песочнице сайта гоняет модель пула: печатает, какой плеер взят под эффект, сколько ссылок живёт и не потерялась ли ни одна — те же инварианты, которые проверяет ваш будущий PlaySfx.
Тик PlaySfx
Соберите поиск свободного плеера в пуле эффектов.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
go:embed: звук внутри бинаря#
Все примеры выше читали звук из байтов в памяти не случайно: файлы проекта встраиваются в бинарь директивой go:embed, которая прилинковывает содержимое папки assets к программе ещё на компиляции. Для звука это три выигрыша сразу: файлы не теряются при переносе бинаря, загрузка мгновенна — декодер ест байты из памяти через bytes.NewReader, — а сборка под другие платформы не тащит за собой папку ассетов. Платить приходится размером исполняемого файла: несжатый WAV растолстеет в разы, поэтому для музыки разумнее Ogg, а WAV оставить коротким эффектам, где секунды сжатия не решают.
Встраивание не отменяет порядок в проекте: подпапки music и sfx внутри assets, осмысленные имена файлов, единая точка загрузки в конструкторе — всё это переживёт момент, когда ассетов станет сотня. Директива работает только с файлами своего пакета, поэтому байты звука объявляют в одном файле ассетов, а остальные пакеты получают готовые переменные. И помните про права: встроенный в бинарь трек с чужой лицензией остаётся чужим даже внутри исполняемого файла, и проверка лицензий — часть релиза, а не дело после него.
Игра научилась звучать: один контекст держит аудиовыход, музыка крутится бесконечным лупом, эффекты снимаются с пула, а громкость и mute подчиняются состоянию игры. Главная привычка урока — владение ссылками: плеер, который должен доиграть, обязан жить в структуре, и проверяется это не тестом, а долгой сессией. Вторая привычка — разделение ролей: короткие звуки и долгая музыка не делят ни плееров, ни логику, и именно поэтому микс не рассыпается при десятке одновременных событий. Дальше курс возвращается к картинке: в следующем уроке HUD и индикаторы соберутся из векторных примитивов без единой картинки, а следом — производительность, где выяснится, сколько стоит каждый такой кадр. Домашнее задание прикрутит звук к вашему проекту, и первый озвученный прыжок изменит ощущение от игры сильнее, чем любая тень.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- audio.Context — один на игру: частота 44100, создание в конструкторе, доступ через CurrentContext.
- Форматы движка — WAV, MP3 и Ogg; декодеры F32 отдают поток сэмплов для NewPlayerF32.
- Ссылка на плеер живёт в структуре или пуле: потеряли — GC остановит звук.
- Эффекты: готовые байты, пул плееров, Rewind и Play — наложение звуков бесплатно.
- Музыка: один долгий плеер с бесконечным лупом, громкость ниже эффектов.
- SetVolume — число от нуля до единицы; mute хранит прошлое значение в состоянии игры.
- Звук встраивается go:embed и едет внутри бинаря на все платформы.
Домашнее задание
Подключите звук к проекту: заведите папку assets с двумя файлами — коротким WAV для прыжка и любым Ogg для фоновой музыки. В конструкторе Game создайте audio.NewContext(44100), декодируйте WAV в байты через wav.DecodeF32 и io.ReadAll, а музыку заверните в NewInfiniteLoopWithIntroF32 и запустите одним плеером. Реализуйте PlaySfx с пулом: свободный плеер переигрывается через Rewind, новый — добавляется в слайс ссылок. Повесьте mute на клавишу M с сохранением прошлой громкости и проверьте живое место: запустите игру минут на пять — если звук обрывается, ищите плеер без ссылок. Сбалансируйте микс: музыка тише эффектов примерно на треть. Затем повесьте отдельный звук на столкновения из урока про коллизии и убедитесь, что десять быстрых прыжков подряд не плодят плееры до бесконечности — пул обязан выйти на плато.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему 44100 герц, а не 48000, как в примерах движка?
Обе частоты — стандарты качественного звука: 44100 пришла из аудио компакт-дисков, 48000 — из видеопроизводства. Разницы на слух между ними нет, а декодеры движка приводят файлы к шкале контекста, поэтому важнее не выбрать идеально, а создать контекст один раз и не держать два в одной программе.
Можно ли генерировать звук без файлов, например писк?
Да: плееру не важно, откуда поток. Реализуйте собственный тип с чтением сэмплов — синус по формуле — и отдайте его NewPlayerF32; примеры sinewave и pcm в поставке движка делают ровно это. Для ретро-эффектов генерация дешевле файлов, и бинарь от неё не толстеет.
Эффектов десятки, и пул растёт. Что делать?
Ограничьте размер пула и при переполнении либо игнорируйте новый вызов, либо забирайте самый старый плеер. На слух очередь из десяти наложенных выстрелов превращается в кашу: три-четыре одновременных звука одного эффекта — практический потолок, дальше игра только шумит.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript